nob.py 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nob/__init__.py +2 -0
- nob/cli/__init__.py +237 -0
- nob/cli/config.py +103 -0
- nob/cli/types.py +1 -0
- nob/human/__init__.py +60 -0
- nob/human/count.py +50 -0
- nob/human/duration.py +62 -0
- nob/human/features/__init__.py +86 -0
- nob/human/throughput.py +52 -0
- nob/ipc/__init__.py +1 -0
- nob/ipc/named_semaphore.py +321 -0
- nob/logging/__init__.py +37 -0
- nob/progress/__init__.py +110 -0
- nob/progress/progress.py +162 -0
- nob/py.typed +0 -0
- nob/time/__init__.py +175 -0
- nob/time/about.py +141 -0
- nob/time/tick.py +46 -0
- nob/utils/__init__.py +1 -0
- nob/utils/auto_numbered_enum.py +46 -0
- nob/utils/join.py +46 -0
- nob_py-0.1.0.dist-info/METADATA +144 -0
- nob_py-0.1.0.dist-info/RECORD +24 -0
- nob_py-0.1.0.dist-info/WHEEL +4 -0
nob/__init__.py
ADDED
nob/cli/__init__.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from collections.abc import Callable
|
|
3
|
+
from typing import ParamSpec, TypeVar
|
|
4
|
+
|
|
5
|
+
import rich_click as click
|
|
6
|
+
|
|
7
|
+
from . import types # noqa: F401
|
|
8
|
+
from .config import AliasedGroup, CLIMutex, Config, pass_config
|
|
9
|
+
|
|
10
|
+
all = ["opt", "cmd", "grp", "pass_config", "pass_context", "types"]
|
|
11
|
+
|
|
12
|
+
P = ParamSpec("P")
|
|
13
|
+
Q = ParamSpec("Q")
|
|
14
|
+
R = TypeVar("R")
|
|
15
|
+
S = TypeVar("S")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def __read_config(ctx: click.Context, _, value: str | None):
|
|
19
|
+
"""Callback that is used whenever Config is passed. We use this to always
|
|
20
|
+
load the correct config. This means that the config is loaded even if the
|
|
21
|
+
group itself never executes so everything always stays available.
|
|
22
|
+
"""
|
|
23
|
+
cfg = ctx.ensure_object(Config)
|
|
24
|
+
cfg.read_config(value)
|
|
25
|
+
return value
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def __read_log_file(ctx: click.Context, _, value: str | None):
|
|
29
|
+
cfg = ctx.ensure_object(Config)
|
|
30
|
+
if value:
|
|
31
|
+
cfg.log_file = value
|
|
32
|
+
return value
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def __read_verbosity(level: int):
|
|
36
|
+
|
|
37
|
+
def callback(ctx: click.Context, _, value: bool):
|
|
38
|
+
cfg = ctx.ensure_object(Config)
|
|
39
|
+
if value:
|
|
40
|
+
cfg.log_level = level
|
|
41
|
+
return value
|
|
42
|
+
|
|
43
|
+
return callback
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def __install_rich_traceback():
|
|
47
|
+
import os
|
|
48
|
+
|
|
49
|
+
from rich.traceback import install
|
|
50
|
+
|
|
51
|
+
DEBUG_TRACE = os.environ.get("DEBUG_TRACE", "0") == "1"
|
|
52
|
+
|
|
53
|
+
extra_lines = 3 if DEBUG_TRACE else 0
|
|
54
|
+
max_frames = 100 if DEBUG_TRACE else 1
|
|
55
|
+
show_locals = DEBUG_TRACE
|
|
56
|
+
|
|
57
|
+
install(
|
|
58
|
+
show_locals=show_locals,
|
|
59
|
+
extra_lines=extra_lines,
|
|
60
|
+
max_frames=max_frames,
|
|
61
|
+
suppress=["click", "rich"],
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def __add_config_options(grp: click.RichGroup | None):
|
|
66
|
+
return (
|
|
67
|
+
[
|
|
68
|
+
click.option(
|
|
69
|
+
"-v",
|
|
70
|
+
"--verbose",
|
|
71
|
+
is_flag=True,
|
|
72
|
+
help="Enable verbose logging (DEBUG min level).",
|
|
73
|
+
callback=__read_verbosity(logging.DEBUG),
|
|
74
|
+
expose_value=False,
|
|
75
|
+
cls=CLIMutex,
|
|
76
|
+
not_required_if=["quiet"],
|
|
77
|
+
),
|
|
78
|
+
click.option(
|
|
79
|
+
"-q",
|
|
80
|
+
"--quiet",
|
|
81
|
+
is_flag=True,
|
|
82
|
+
help="Enable quiet logging (WARNING min level).",
|
|
83
|
+
callback=__read_verbosity(logging.WARNING),
|
|
84
|
+
expose_value=False,
|
|
85
|
+
cls=CLIMutex,
|
|
86
|
+
not_required_if=["verbose"],
|
|
87
|
+
),
|
|
88
|
+
click.option(
|
|
89
|
+
"-c",
|
|
90
|
+
"--config",
|
|
91
|
+
type=click.Path(exists=True, dir_okay=False),
|
|
92
|
+
help="Path to a custom config file to load instead of the default (defaults to assets/cfg/default.yml).",
|
|
93
|
+
callback=__read_config,
|
|
94
|
+
expose_value=False,
|
|
95
|
+
),
|
|
96
|
+
click.option(
|
|
97
|
+
"-l",
|
|
98
|
+
"--log-file",
|
|
99
|
+
type=click.Path(dir_okay=False),
|
|
100
|
+
help="Specify the path where the RotatingFileHandler will write its outputs.",
|
|
101
|
+
callback=__read_log_file,
|
|
102
|
+
expose_value=False,
|
|
103
|
+
),
|
|
104
|
+
]
|
|
105
|
+
if grp is None
|
|
106
|
+
else []
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def __preserve_click_params(func: Callable[P, R], wrapper: Callable[Q, S]):
|
|
111
|
+
"""Modifies the wrapper function in-place to have the same Click parameters as the original function.\\
|
|
112
|
+
It ensures that the decorators can be used in any order without breaking the underlying Click parameters.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
func (Callable[P, R]): The original function with the correct Click parameters.
|
|
116
|
+
wrapper (Callable[Q, S]): The wrapper function that needs to have the Click parameters of the original function.
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
Callable[Q, S]: wrapper (so that this function can be nicely chained)
|
|
120
|
+
"""
|
|
121
|
+
# https://stackoverflow.com/q/57773853#comment101986419_57773853
|
|
122
|
+
if hasattr(func, "__click_params__"):
|
|
123
|
+
assert isinstance(func.__click_params__, list)
|
|
124
|
+
# Actually creates the attribute on the wrapper if it doesn't exist
|
|
125
|
+
wrapper.__click_params__ = func.__click_params__ # ty:ignore[unresolved-attribute]
|
|
126
|
+
return wrapper
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def grp(
|
|
130
|
+
grp: click.RichGroup | None = None,
|
|
131
|
+
default: Callable[[], click.RichCommand] | None = None,
|
|
132
|
+
*default_args,
|
|
133
|
+
**default_kwargs,
|
|
134
|
+
) -> click.RichGroup:
|
|
135
|
+
"""Registers the decorated function as a Click group and adds the common options to it.\\
|
|
136
|
+
Please provide default arguments (that don't have defaults defined) since you won't be able to do so in the CLI.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
grp (click.RichGroup, optional): Parent group to attach the group to. Defaults to None.
|
|
140
|
+
default (() -> RichCommand, optional): Factory of the default command to run if nothing is passed. Defaults to None.
|
|
141
|
+
*default_args (): Default arguments.
|
|
142
|
+
**default_kwargs (): Default named arguments.
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
entity = grp or click
|
|
146
|
+
|
|
147
|
+
def inner(main: Callable[P, R]):
|
|
148
|
+
dec = (
|
|
149
|
+
[
|
|
150
|
+
entity.group(
|
|
151
|
+
name=main.__name__, # ty:ignore[unresolved-attribute]
|
|
152
|
+
help=main.__doc__,
|
|
153
|
+
cls=AliasedGroup,
|
|
154
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
155
|
+
invoke_without_command=default is not None,
|
|
156
|
+
)
|
|
157
|
+
]
|
|
158
|
+
+ __add_config_options(grp)
|
|
159
|
+
+ [
|
|
160
|
+
click.pass_context,
|
|
161
|
+
]
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
def wrapper(ctx: click.Context, *args, **kwargs):
|
|
165
|
+
if default is not None and ctx.invoked_subcommand is None:
|
|
166
|
+
ctx.forward(default(), *default_args, **default_kwargs)
|
|
167
|
+
return main(*args, **kwargs)
|
|
168
|
+
|
|
169
|
+
__preserve_click_params(main, wrapper)
|
|
170
|
+
for d in reversed(dec):
|
|
171
|
+
wrapper = d(wrapper)
|
|
172
|
+
return wrapper
|
|
173
|
+
|
|
174
|
+
return inner # ty:ignore[invalid-return-type]
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def cmd(grp: click.RichGroup | None = None) -> click.RichCommand:
|
|
178
|
+
"""Decorator to create a command. Can be attached to a group.\\
|
|
179
|
+
Adds the following parameters to the command if they are present in the function signature or if the function accepts `**kwargs`:
|
|
180
|
+
- `cfg`: The Config object `nob.cli.config.Config`
|
|
181
|
+
- `ctx`: The Click context object `rich_click.Context`
|
|
182
|
+
- `lg`: A logger with the name of the command `logging.Logger`
|
|
183
|
+
"""
|
|
184
|
+
entity = grp or click
|
|
185
|
+
|
|
186
|
+
def inner(func: Callable[P, R]):
|
|
187
|
+
dec = (
|
|
188
|
+
[
|
|
189
|
+
entity.command(
|
|
190
|
+
name=(name := func.__name__), # ty:ignore[unresolved-attribute]
|
|
191
|
+
help=func.__doc__,
|
|
192
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
193
|
+
),
|
|
194
|
+
]
|
|
195
|
+
+ __add_config_options(grp)
|
|
196
|
+
+ [
|
|
197
|
+
click.pass_context,
|
|
198
|
+
pass_config,
|
|
199
|
+
]
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
def wrapper(cfg: Config, ctx: click.Context, **kwargs):
|
|
203
|
+
import inspect
|
|
204
|
+
|
|
205
|
+
from nob.logging import init_handler
|
|
206
|
+
|
|
207
|
+
lg = logging.getLogger(name)
|
|
208
|
+
init_handler(cfg.log_level, cfg.log_file)
|
|
209
|
+
|
|
210
|
+
sig = inspect.signature(func)
|
|
211
|
+
has_kwargs = any(p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values())
|
|
212
|
+
|
|
213
|
+
kw = dict(kwargs)
|
|
214
|
+
if has_kwargs or "cfg" in sig.parameters:
|
|
215
|
+
kw["cfg"] = cfg
|
|
216
|
+
if has_kwargs or "ctx" in sig.parameters:
|
|
217
|
+
kw["ctx"] = ctx
|
|
218
|
+
if has_kwargs or "lg" in sig.parameters:
|
|
219
|
+
kw["lg"] = lg
|
|
220
|
+
|
|
221
|
+
return func(**kw) # ty:ignore[missing-argument]
|
|
222
|
+
|
|
223
|
+
__preserve_click_params(func, wrapper)
|
|
224
|
+
for d in reversed(dec):
|
|
225
|
+
wrapper = d(wrapper)
|
|
226
|
+
return wrapper
|
|
227
|
+
|
|
228
|
+
__install_rich_traceback()
|
|
229
|
+
return inner # ty:ignore[invalid-return-type]
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
opt = click.option
|
|
233
|
+
arg = click.argument
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def pass_context(func):
|
|
237
|
+
return click.pass_context(func)
|
nob/cli/config.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import rich_click as click
|
|
6
|
+
import yaml
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
from typing_extensions import override
|
|
9
|
+
|
|
10
|
+
__all__ = ["AliasedGroup", "Config", "pass_config", "CLIMutex"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Config(BaseModel):
|
|
14
|
+
"""The config."""
|
|
15
|
+
|
|
16
|
+
DEFAULT_CONFIG_PATH: str = os.path.join("assets", "cfg", "default.yml")
|
|
17
|
+
|
|
18
|
+
aliases: dict[str, str] = Field(default_factory=dict)
|
|
19
|
+
log_level: int = logging.INFO
|
|
20
|
+
log_file: str | None = None
|
|
21
|
+
|
|
22
|
+
def add_alias(self, alias: str, cmd: str):
|
|
23
|
+
self.aliases[alias] = cmd
|
|
24
|
+
|
|
25
|
+
def read_config(self, filename: str | None):
|
|
26
|
+
try:
|
|
27
|
+
with open(self.DEFAULT_CONFIG_PATH) as f:
|
|
28
|
+
config_data: dict[str, Any] = yaml.safe_load(f) or {}
|
|
29
|
+
except FileNotFoundError:
|
|
30
|
+
config_data = {}
|
|
31
|
+
if filename and os.path.abspath(filename) != os.path.abspath(self.DEFAULT_CONFIG_PATH):
|
|
32
|
+
with open(filename) as f:
|
|
33
|
+
user_config = yaml.safe_load(f)
|
|
34
|
+
if user_config:
|
|
35
|
+
config_data.update(user_config)
|
|
36
|
+
|
|
37
|
+
if not config_data:
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
# aliases
|
|
41
|
+
self.aliases.update(config_data.get("aliases", {}))
|
|
42
|
+
|
|
43
|
+
# options
|
|
44
|
+
options: dict[str, Any] = config_data.get("options", {}) # noqa: F841
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
pass_config = click.make_pass_decorator(Config, ensure=True)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class AliasedGroup(click.RichGroup):
|
|
51
|
+
"""Aliased rich-click.Group"""
|
|
52
|
+
|
|
53
|
+
@override
|
|
54
|
+
def get_command(self, ctx, cmd_name):
|
|
55
|
+
rv = click.Group.get_command(self, ctx, cmd_name)
|
|
56
|
+
if rv is not None:
|
|
57
|
+
return rv
|
|
58
|
+
|
|
59
|
+
cfg = ctx.ensure_object(Config)
|
|
60
|
+
|
|
61
|
+
if cmd_name in cfg.aliases:
|
|
62
|
+
actual_cmd = cfg.aliases[cmd_name]
|
|
63
|
+
return click.Group.get_command(self, ctx, actual_cmd)
|
|
64
|
+
|
|
65
|
+
matches = [x for x in self.list_commands(ctx) if x.lower().startswith(cmd_name.lower())]
|
|
66
|
+
if not matches:
|
|
67
|
+
return None
|
|
68
|
+
elif len(matches) == 1:
|
|
69
|
+
return click.Group.get_command(self, ctx, matches[0])
|
|
70
|
+
ctx.fail(f"Too many matches: {', '.join(sorted(matches))}")
|
|
71
|
+
|
|
72
|
+
@override
|
|
73
|
+
def resolve_command(self, ctx, args):
|
|
74
|
+
_, cmd, args = super().resolve_command(ctx, args)
|
|
75
|
+
assert cmd is not None
|
|
76
|
+
return cmd.name, cmd, args
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class CLIMutex(click.Option):
|
|
80
|
+
def __init__(self, *args, **kwargs):
|
|
81
|
+
self.not_required_if: list = kwargs.pop("not_required_if")
|
|
82
|
+
|
|
83
|
+
assert self.not_required_if, "'not_required_if' parameter required"
|
|
84
|
+
help = f"{h} " if (h := kwargs.get("help", "")) else ""
|
|
85
|
+
kwargs["help"] = f"{help}Mutually exclusive with {', '.join(self.not_required_if)}.".strip()
|
|
86
|
+
super().__init__(*args, **kwargs)
|
|
87
|
+
|
|
88
|
+
@override
|
|
89
|
+
def handle_parse_result(self, ctx, opts, args):
|
|
90
|
+
current_opt: bool = self.name in opts
|
|
91
|
+
for mutex_opt in self.not_required_if:
|
|
92
|
+
if mutex_opt in opts:
|
|
93
|
+
if current_opt:
|
|
94
|
+
raise click.UsageError(
|
|
95
|
+
"Illegal usage: '"
|
|
96
|
+
+ str(self.name)
|
|
97
|
+
+ "' is mutually exclusive with "
|
|
98
|
+
+ str(mutex_opt)
|
|
99
|
+
+ "."
|
|
100
|
+
)
|
|
101
|
+
else:
|
|
102
|
+
self.prompt = None
|
|
103
|
+
return super().handle_parse_result(ctx, opts, args)
|
nob/cli/types.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from rich_click import Choice, File, FloatRange, IntRange, ParamType, Path # noqa: F401
|
nob/human/__init__.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from .count import HumanCount
|
|
2
|
+
from .duration import HumanDuration
|
|
3
|
+
from .features import FEATURES
|
|
4
|
+
from .throughput import HumanThroughput
|
|
5
|
+
|
|
6
|
+
__all__ = ["FEATURES", "count", "duration", "throughput"]
|
|
7
|
+
# revival of https://github.com/rsalmei/about-time and pending PRs
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def count(value: int | float, unit: str = ""):
|
|
11
|
+
"""Get a renderable human-friendly representation of a count.
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
value (int | float): The count value to be represented in a human-friendly format. Must be a non-negative integer or float.
|
|
15
|
+
unit (str, optional): The unit of the count. Defaults to "".
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
HumanCount: A human-friendly representation of the count.
|
|
19
|
+
|
|
20
|
+
Example:
|
|
21
|
+
>>> from nob import human
|
|
22
|
+
>>> print(human.count(123456789))
|
|
23
|
+
123.46M
|
|
24
|
+
"""
|
|
25
|
+
return HumanCount(value, unit)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def duration(value: int | float):
|
|
29
|
+
"""Get a renderable human-friendly representation of a duration in seconds.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
value (int | float): The duration value in seconds to be represented in a human-friendly format. Must be a non-negative integer or float.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
HumanDuration: A human-friendly representation of the duration.
|
|
36
|
+
|
|
37
|
+
Example:
|
|
38
|
+
>>> from nob import human
|
|
39
|
+
>>> print(human.duration(123.456))
|
|
40
|
+
2:03.5
|
|
41
|
+
"""
|
|
42
|
+
return HumanDuration(value)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def throughput(value: int | float, unit: str = "it"):
|
|
46
|
+
"""Get a renderable human-friendly representation of a throughput in units per second.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
value (int | float): The throughput value in units per second to be represented in a human-friendly format. Must be a non-negative integer or float.
|
|
50
|
+
unit (str, optional): The unit of the throughput. Defaults to "it".
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
HumanThroughput: A human-friendly representation of the throughput.
|
|
54
|
+
|
|
55
|
+
Example:
|
|
56
|
+
>>> from nob import human
|
|
57
|
+
>>> print(human.throughput(0.123))
|
|
58
|
+
7.4it/m
|
|
59
|
+
"""
|
|
60
|
+
return HumanThroughput(value, unit)
|
nob/human/count.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from typing_extensions import override
|
|
2
|
+
|
|
3
|
+
from .features import FEATURES, HumanWithUnit, conv_space
|
|
4
|
+
|
|
5
|
+
__all__ = ["HumanCount"]
|
|
6
|
+
|
|
7
|
+
SI_1000_SPEC = ("", "k", "M", "G", "T", "P", "E", "Z", "Y")
|
|
8
|
+
SI_1024_SPEC = ("", "K", "M", "G", "T", "P", "E", "Z", "Y")
|
|
9
|
+
IEC_1024_SPEC = ("", "Ki", "Mi", "Gi", "Ti", "Pi", "Ei", "Zi", "Yi")
|
|
10
|
+
DECIMALS = [1, 1, 1, 2, 2, 2, 2, 2, 2]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def __human_count(val: float, unit: str, prec: int | None, space: str, divisor: int, spec: tuple) -> str:
|
|
14
|
+
for scale, dec in zip(spec, DECIMALS, strict=True): # noqa: B007
|
|
15
|
+
r = round(val, dec)
|
|
16
|
+
if r >= divisor:
|
|
17
|
+
val /= divisor
|
|
18
|
+
continue
|
|
19
|
+
break
|
|
20
|
+
else:
|
|
21
|
+
r, scale = val, "+"
|
|
22
|
+
|
|
23
|
+
if prec is not None:
|
|
24
|
+
r = round(val, prec)
|
|
25
|
+
elif r % 1.0 == 0.0:
|
|
26
|
+
prec = 0
|
|
27
|
+
elif (r * 10.0) % 1.0 == 0.0:
|
|
28
|
+
prec = 1
|
|
29
|
+
else:
|
|
30
|
+
prec = 2
|
|
31
|
+
return "{:.{}f}{}{}{}".format(r, prec, space, scale, unit)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def fn_human_count(show_space: bool, d1024: bool, iec: bool):
|
|
35
|
+
def run(val: float, unit: str, prec: int | None = None):
|
|
36
|
+
return __human_count(val, unit, prec, space, divisor, spec)
|
|
37
|
+
|
|
38
|
+
space = conv_space(show_space)
|
|
39
|
+
divisor, spec = 1024, IEC_1024_SPEC
|
|
40
|
+
if not iec:
|
|
41
|
+
divisor, spec = 1024 if d1024 else 1000, SI_1000_SPEC
|
|
42
|
+
return run
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class HumanCount(HumanWithUnit):
|
|
46
|
+
@override
|
|
47
|
+
def str(self, prec: int | None = None) -> str:
|
|
48
|
+
return fn_human_count(FEATURES.feature_space, FEATURES.feature_1024, FEATURES.feature_iec)(
|
|
49
|
+
self.value, self.unit, prec
|
|
50
|
+
)
|
nob/human/duration.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from typing_extensions import override
|
|
2
|
+
|
|
3
|
+
from .features import FEATURES, Human, conv_space
|
|
4
|
+
|
|
5
|
+
__all__ = ["HumanDuration"]
|
|
6
|
+
|
|
7
|
+
SPEC = (
|
|
8
|
+
(1e3, 1e3, "ns", 1),
|
|
9
|
+
(1e3, 1e3, "µs", 1), # uses non-ASCII “µs” suffix.
|
|
10
|
+
(1e3, 1e3, "ms", 1),
|
|
11
|
+
(60.0, 1.0, "s", 2),
|
|
12
|
+
# 1:01.1 (minutes in code, 1 decimal).
|
|
13
|
+
# 1:01:01 (hours in code, 0 decimal).
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def __human_duration(val: float, prec: int | None, space: str) -> str:
|
|
18
|
+
val *= 1e9
|
|
19
|
+
for size, div_next, scale, dec in SPEC:
|
|
20
|
+
r = round(val, dec)
|
|
21
|
+
if r >= size:
|
|
22
|
+
val /= div_next
|
|
23
|
+
continue
|
|
24
|
+
|
|
25
|
+
if prec is not None:
|
|
26
|
+
r = round(val, prec)
|
|
27
|
+
elif r % 1.0 == 0.0:
|
|
28
|
+
prec = 0
|
|
29
|
+
elif (r * 10.0) % 1.0 == 0.0:
|
|
30
|
+
prec = 1
|
|
31
|
+
else:
|
|
32
|
+
prec = 2
|
|
33
|
+
return "{:.{}f}{}{}".format(r, prec, space, scale)
|
|
34
|
+
|
|
35
|
+
val = round(val, 1)
|
|
36
|
+
m = val / 60.0
|
|
37
|
+
if m < 60.0:
|
|
38
|
+
r = val % 60.0
|
|
39
|
+
if prec is not None:
|
|
40
|
+
pass
|
|
41
|
+
elif r % 1.0 == 0.0:
|
|
42
|
+
prec = 0
|
|
43
|
+
|
|
44
|
+
if prec == 0:
|
|
45
|
+
return "{:.0f}:{:02.0f}".format(m // 1.0, r)
|
|
46
|
+
return "{:.0f}:{:04.1f}".format(m // 1.0, round(r, 1))
|
|
47
|
+
|
|
48
|
+
return "{:.0f}:{:02.0f}:{:02.0f}".format(m / 60.0 // 1.0, m % 60.0 // 1.0, val % 60.0 // 1.0)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def fn_human_duration(show_space: bool):
|
|
52
|
+
def run(val, prec: int | None = None):
|
|
53
|
+
return __human_duration(val, prec, space)
|
|
54
|
+
|
|
55
|
+
space = conv_space(show_space)
|
|
56
|
+
return run
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class HumanDuration(Human):
|
|
60
|
+
@override
|
|
61
|
+
def str(self, prec: int | None = None) -> str:
|
|
62
|
+
return fn_human_duration(FEATURES.feature_space)(self.value, prec)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Features:
|
|
5
|
+
def __init__(self):
|
|
6
|
+
self._feature_space = False
|
|
7
|
+
self._feature_1024 = False
|
|
8
|
+
self._feature_iec = False
|
|
9
|
+
|
|
10
|
+
@property
|
|
11
|
+
def feature_space(self) -> bool:
|
|
12
|
+
return self._feature_space
|
|
13
|
+
|
|
14
|
+
@property
|
|
15
|
+
def feature_1024(self) -> bool:
|
|
16
|
+
return self._feature_1024
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def feature_iec(self) -> bool:
|
|
20
|
+
return self._feature_iec
|
|
21
|
+
|
|
22
|
+
@feature_space.setter
|
|
23
|
+
def feature_space(self, value: bool):
|
|
24
|
+
self._feature_space = bool(value)
|
|
25
|
+
|
|
26
|
+
@feature_1024.setter
|
|
27
|
+
def feature_1024(self, value: bool):
|
|
28
|
+
self._feature_1024 = bool(value)
|
|
29
|
+
|
|
30
|
+
@feature_iec.setter
|
|
31
|
+
def feature_iec(self, value: bool):
|
|
32
|
+
self._feature_iec = bool(value)
|
|
33
|
+
self.feature_1024 = value
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def conv_space(space: bool) -> str:
|
|
37
|
+
return " " if space else ""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
FEATURES = Features()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Human(ABC):
|
|
44
|
+
def __init__(self, value: int | float):
|
|
45
|
+
assert value >= 0.0
|
|
46
|
+
self.__value = value
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def value(self):
|
|
50
|
+
return self.__value
|
|
51
|
+
|
|
52
|
+
@abstractmethod
|
|
53
|
+
def str(self, prec: int | None = None) -> str:
|
|
54
|
+
"""Return a human-friendly representation of the value. It dynamically calculates the best scale to use.\\
|
|
55
|
+
You don't need to call this method directly, just use `str()` or `print()` on the object.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
prec: an optional custom precision to use in the representation. Defaults to None.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
str: A human-friendly representation of the value.
|
|
62
|
+
"""
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
def __str__(self):
|
|
66
|
+
return self.str()
|
|
67
|
+
|
|
68
|
+
def __repr__(self):
|
|
69
|
+
return "{}{{ value={} }} -> {}".format(self.__class__.__name__, self.__value, self)
|
|
70
|
+
|
|
71
|
+
def __eq__(self, other):
|
|
72
|
+
return self.__str__() == other
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class HumanWithUnit(Human):
|
|
76
|
+
def __init__(self, value: int | float, unit: str = ""):
|
|
77
|
+
super().__init__(value)
|
|
78
|
+
self.__unit = unit
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def unit(self) -> str:
|
|
82
|
+
return self.__unit
|
|
83
|
+
|
|
84
|
+
def with_unit(self, value: str):
|
|
85
|
+
"""Return a new instance of the same class with the same value but a different unit."""
|
|
86
|
+
return self.__class__(self.value, value)
|
nob/human/throughput.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from typing_extensions import override
|
|
2
|
+
|
|
3
|
+
from .count import fn_human_count
|
|
4
|
+
from .features import FEATURES, HumanWithUnit, conv_space
|
|
5
|
+
|
|
6
|
+
__all__ = ["HumanThroughput"]
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
SPEC = (
|
|
10
|
+
(24.0, "/d", 2),
|
|
11
|
+
(60.0, "/h", 1),
|
|
12
|
+
(60.0, "/m", 1),
|
|
13
|
+
# "/s" in code.
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def __human_throughput(val: float, unit: str, prec: int | None, space: str, fn_count) -> str:
|
|
18
|
+
val *= 60.0 * 60.0 * 24.0
|
|
19
|
+
for size, scale, dec in SPEC:
|
|
20
|
+
r = round(val, dec)
|
|
21
|
+
if r >= size:
|
|
22
|
+
val /= size
|
|
23
|
+
continue
|
|
24
|
+
|
|
25
|
+
if prec is not None:
|
|
26
|
+
r = round(val, prec)
|
|
27
|
+
elif r % 1.0 == 0.0:
|
|
28
|
+
prec = 0
|
|
29
|
+
elif (r * 10.0) % 1.0 == 0.0:
|
|
30
|
+
prec = 1
|
|
31
|
+
else:
|
|
32
|
+
prec = 2
|
|
33
|
+
return "{:.{}f}{}{}{}".format(r, prec, space, unit, scale)
|
|
34
|
+
|
|
35
|
+
return "{}/s".format(fn_count(val, unit, prec))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def fn_human_throughput(show_space: bool, d1024: bool, iec: bool):
|
|
39
|
+
def run(val: float, unit: str, prec: int | None = None):
|
|
40
|
+
return __human_throughput(val, unit, prec, space, fn_count)
|
|
41
|
+
|
|
42
|
+
fn_count = fn_human_count(show_space, d1024, iec)
|
|
43
|
+
space = conv_space(show_space)
|
|
44
|
+
return run
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class HumanThroughput(HumanWithUnit):
|
|
48
|
+
@override
|
|
49
|
+
def str(self, prec: int | None = None) -> str:
|
|
50
|
+
return fn_human_throughput(FEATURES.feature_space, FEATURES.feature_1024, FEATURES.feature_iec)(
|
|
51
|
+
self.value, self.unit, prec
|
|
52
|
+
)
|
nob/ipc/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .named_semaphore import *
|