source: rattail/rattail/util.py @ 93daa31

Last change on this file since 93daa31 was 93daa31, checked in by Lance Edgar <ledgar@…>, 12 months ago

Tweak pretty_hours() to better handle negative values

  • Property mode set to 100644
File size: 7.7 KB
Line 
1# -*- coding: utf-8; -*-
2################################################################################
3#
4#  Rattail -- Retail Software Framework
5#  Copyright © 2010-2019 Lance Edgar
6#
7#  This file is part of Rattail.
8#
9#  Rattail is free software: you can redistribute it and/or modify it under the
10#  terms of the GNU General Public License as published by the Free Software
11#  Foundation, either version 3 of the License, or (at your option) any later
12#  version.
13#
14#  Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
15#  WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
16#  FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
17#  details.
18#
19#  You should have received a copy of the GNU General Public License along with
20#  Rattail.  If not, see <http://www.gnu.org/licenses/>.
21#
22################################################################################
23"""
24Utilities
25"""
26
27from __future__ import unicode_literals, absolute_import
28from __future__ import division
29
30import sys
31import datetime
32import decimal
33import subprocess
34
35import six
36from pkg_resources import iter_entry_points
37
38try:
39    from collections import OrderedDict
40except ImportError: # pragma no cover
41    from ordereddict import OrderedDict
42
43
44# generic singleton to indicate an arg which isn't set etc.
45NOTSET = object()
46
47
48def capture_output(command):
49    """
50    Runs ``command`` and returns any output it produces.
51    """
52    # We *need* to pipe ``stdout`` because that's how we capture the output of
53    # the ``hg`` command.  However, we must pipe *all* handles in order to
54    # prevent issues when running as a GUI but *from* the Windows console.  See
55    # also: http://bugs.python.org/issue3905
56    kwargs = dict(stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
57    output = subprocess.Popen(command, **kwargs).communicate()[0]
58    return output
59
60
61def data_diffs(local_data, host_data, fields=None):
62    """
63    Find all (relevant) fields which differ between the host and local data
64    values for a given record.
65    """
66    if fields is None:
67        fields = list(local_data)
68    diffs = []
69    for field in fields:
70        if local_data[field] != host_data[field]:
71            diffs.append(field)
72    return diffs
73
74
75def import_module_path(module_path):
76    """
77    Import an arbitrary Python module.
78
79    :param module_path: String referencing a module by its "dotted path".
80
81    :returns: The referenced module.
82    """
83    if module_path in sys.modules:
84        return sys.modules[module_path]
85    module = __import__(module_path)
86
87    def last_module(module, module_path):
88        parts = module_path.split('.')
89        parts.pop(0)
90        child = getattr(module, parts[0])
91        if len(parts) == 1:
92            return child
93        return last_module(child, '.'.join(parts))
94
95    return last_module(module, module_path)
96
97
98def get_object_spec(obj):
99    """
100    Returns the "object spec" string for the given object.
101    """
102    # TODO: this only handles a class *instance* currently
103    return '{}:{}'.format(obj.__module__, obj.__class__.__name__)
104
105
106def load_object(specifier):
107    """
108    Load an arbitrary object from a module, according to a specifier.
109
110    The specifier string should contain a dotted path to an importable module,
111    followed by a colon (``':'``), followed by the name of the object to be
112    loaded.  For example:
113
114    .. code-block:: none
115
116       rattail.files:overwriting_move
117
118    You'll notice from this example that "object" in this context refers to any
119    valid Python object, i.e. not necessarily a class instance.  The name may
120    refer to a class, function, variable etc.  Once the module is imported, the
121    ``getattr()`` function is used to obtain a reference to the named object;
122    therefore anything supported by that method should work.
123
124    :param specifier: Specifier string.
125
126    :returns: The specified object.
127    """
128    module_path, name = specifier.split(':')
129    module = import_module_path(module_path)
130    return getattr(module, name)
131
132
133def load_entry_points(group):
134    """
135    Load a set of ``setuptools``-style entry points.
136
137    This is a convenience wrapper around ``pkg_resources.iter_entry_points()``.
138
139    :param group: The group of entry points to be loaded.
140
141    :returns: A dictionary whose keys are the entry point names, and values are
142       the loaded entry points.
143    """
144    entry_points = {}
145    for entry_point in iter_entry_points(group):
146        entry_points[entry_point.name] = entry_point.load()
147    return entry_points
148
149
150def prettify(text):
151    """
152    Return a "prettified" version of text.
153    """
154    text = text.replace('_', ' ')
155    text = text.replace('-', ' ')
156    words = text.split()
157    return ' '.join([x.capitalize() for x in words])
158
159
160def pretty_boolean(value):
161    """
162    Returns ``'Yes'`` or ``'No'`` or empty string if value is ``None``
163    """
164    if value is None:
165        return ""
166    return "Yes" if value else "No"
167
168
169def hours_as_decimal(hours, places=2):
170    """
171    Convert the given ``datetime.timedelta`` object into a Decimal whose
172    value is in terms of hours.
173    """
174    if hours is None:
175        return
176    minutes = (hours.days * 1440) + (hours.seconds // 60)
177    fmt = '{{:0.{}f}}'.format(places)
178    return decimal.Decimal(fmt.format(minutes / 60.0))
179
180
181def pretty_hours(hours=None, seconds=None):
182    """
183    Format the given ``hours`` value (which is assumed to be a
184    ``datetime.timedelta`` object) as HH:MM.  Note that instead of providing
185    that, you can provide ``seconds`` instead and a delta will be generated.
186    """
187    if hours is None and seconds is None:
188        return ''
189    if hours is None:
190        hours = datetime.timedelta(seconds=seconds)
191
192    # determine if hours value is positive or negative.  seems like some of the
193    # math can be "off" if negative values are involved, in which case we'll
194    # convert to positive for sake of math and then prefix result with a hyphen
195    negative = False
196
197    if isinstance(hours, decimal.Decimal):
198        if hours < 0:
199            negative = True
200            hours = -hours
201        hours = datetime.timedelta(seconds=int(hours * 3600))
202
203    minutes = (hours.days * 1440) + (hours.seconds / 60)
204    rendered = "{}:{:02d}".format(int(minutes // 60), int(minutes % 60))
205    if negative:
206        rendered = "-{}".format(rendered)
207    return rendered
208
209
210def pretty_quantity(value, empty_zero=False):
211    """
212    Return a "pretty" version of the given value, as string.  This is meant primarily
213    for use with things like order quantities, so that e.g. 1.0000 => 1
214    """
215    if value is None:
216        return ''
217    if int(value) == value:
218        value = int(value)
219        if empty_zero and value == 0:
220            return ''
221        return six.text_type(value)
222    return six.text_type(value).rstrip('0')
223
224
225def progress_loop(func, items, factory, *args, **kwargs):
226    """
227    This will iterate over ``items`` and call ``func`` for each.  If a progress
228    ``factory`` kwarg is provided, then a progress instance will be created and
229    updated along the way.
230    """
231    message = kwargs.pop('message', None)
232    count = kwargs.pop('count', None)
233    allow_cancel = kwargs.pop('allow_cancel', False)
234    if count is None:
235        try:
236            count = len(items)
237        except TypeError:
238            count = items.count()
239    if not count:
240        return True
241
242    prog = None
243    if factory:
244        prog = factory(message, count)
245
246    canceled = False
247    for i, item in enumerate(items, 1):
248        func(item, i, *args, **kwargs)
249        if prog and not prog.update(i):
250            canceled = True
251            break
252    if prog:
253        prog.finish()
254    if canceled and not allow_cancel:
255        raise RuntimeError("Operation was canceled")
256    return not canceled
Note: See TracBrowser for help on using the repository browser.