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 ADDED
@@ -0,0 +1,2 @@
1
+ def hello() -> str:
2
+ return "Hello from nob!"
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)
@@ -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 *