dbworkload 0.9.1__tar.gz → 0.9.2.dev1__tar.gz
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.
- {dbworkload-0.9.1 → dbworkload-0.9.2.dev1}/PKG-INFO +2 -5
- {dbworkload-0.9.1 → dbworkload-0.9.2.dev1}/dbworkload/models/run.py +200 -97
- {dbworkload-0.9.1 → dbworkload-0.9.2.dev1}/dbworkload/utils/common.py +9 -0
- {dbworkload-0.9.1 → dbworkload-0.9.2.dev1}/pyproject.toml +7 -2
- {dbworkload-0.9.1 → dbworkload-0.9.2.dev1}/LICENSE +0 -0
- {dbworkload-0.9.1 → dbworkload-0.9.2.dev1}/README.md +0 -0
- {dbworkload-0.9.1 → dbworkload-0.9.2.dev1}/dbworkload/__init__.py +0 -0
- {dbworkload-0.9.1 → dbworkload-0.9.2.dev1}/dbworkload/cli/dep.py +0 -0
- {dbworkload-0.9.1 → dbworkload-0.9.2.dev1}/dbworkload/cli/main.py +0 -0
- {dbworkload-0.9.1 → dbworkload-0.9.2.dev1}/dbworkload/cli/util.py +0 -0
- {dbworkload-0.9.1 → dbworkload-0.9.2.dev1}/dbworkload/models/util.py +0 -0
- {dbworkload-0.9.1 → dbworkload-0.9.2.dev1}/dbworkload/templates/stub.j2 +0 -0
- {dbworkload-0.9.1 → dbworkload-0.9.2.dev1}/dbworkload/utils/simplefaker.py +0 -0
|
@@ -1,17 +1,14 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: dbworkload
|
|
3
|
-
Version: 0.9.
|
|
3
|
+
Version: 0.9.2.dev1
|
|
4
4
|
Summary: Workload framework
|
|
5
5
|
License: GPLv3+
|
|
6
6
|
Author: Fabio Ghirardello
|
|
7
|
-
Requires-Python: >=3.
|
|
7
|
+
Requires-Python: >=3.11,<4.0
|
|
8
8
|
Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
|
|
9
9
|
Classifier: License :: Other/Proprietary License
|
|
10
10
|
Classifier: Operating System :: OS Independent
|
|
11
11
|
Classifier: Programming Language :: Python :: 3
|
|
12
|
-
Classifier: Programming Language :: Python :: 3.8
|
|
13
|
-
Classifier: Programming Language :: Python :: 3.9
|
|
14
|
-
Classifier: Programming Language :: Python :: 3.10
|
|
15
12
|
Classifier: Programming Language :: Python :: 3.11
|
|
16
13
|
Classifier: Programming Language :: Python :: 3.12
|
|
17
14
|
Classifier: Programming Language :: Python :: 3.13
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import errno
|
|
4
4
|
import logging
|
|
5
|
+
import math
|
|
5
6
|
import multiprocessing as mp
|
|
6
7
|
import os
|
|
7
8
|
import queue
|
|
@@ -19,6 +20,7 @@ from psutil import cpu_percent, virtual_memory
|
|
|
19
20
|
|
|
20
21
|
import dbworkload.utils.common
|
|
21
22
|
from dbworkload.cli.dep import ConnInfo
|
|
23
|
+
from dbworkload.utils.common import Action
|
|
22
24
|
|
|
23
25
|
# from cassandra.cluster import Cluster, ExecutionProfile, EXEC_PROFILE_DEFAULT, Session
|
|
24
26
|
# from cassandra.policies import (
|
|
@@ -40,6 +42,9 @@ logger = logging.getLogger("dbworkload")
|
|
|
40
42
|
|
|
41
43
|
sigterm_received = False
|
|
42
44
|
|
|
45
|
+
supervisors: dict[int, mp.Process] = {}
|
|
46
|
+
supervisor_queues: dict[int, mp.Queue] = {}
|
|
47
|
+
|
|
43
48
|
HEADERS: list = [
|
|
44
49
|
"elapsed",
|
|
45
50
|
"id",
|
|
@@ -125,7 +130,7 @@ def cycle(iterable, backwards=False):
|
|
|
125
130
|
# If a ramp time is specified, threads creation or destruction
|
|
126
131
|
# will be paced accordingly.
|
|
127
132
|
def launch_or_kill_workers(
|
|
128
|
-
|
|
133
|
+
supervisor_queues: list,
|
|
129
134
|
ramp_time: int,
|
|
130
135
|
cc_change: int,
|
|
131
136
|
proc_len: list,
|
|
@@ -140,11 +145,14 @@ def launch_or_kill_workers(
|
|
|
140
145
|
|
|
141
146
|
if cc_change > 0:
|
|
142
147
|
for _ in range(cc_change):
|
|
143
|
-
|
|
148
|
+
supervisor_queues[cycle(proc_len)].put(
|
|
144
149
|
(
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
150
|
+
Action.NEW_WORKER,
|
|
151
|
+
(
|
|
152
|
+
thread_id,
|
|
153
|
+
iterations_per_thread,
|
|
154
|
+
concurrency,
|
|
155
|
+
),
|
|
148
156
|
)
|
|
149
157
|
)
|
|
150
158
|
thread_id += 1
|
|
@@ -152,7 +160,7 @@ def launch_or_kill_workers(
|
|
|
152
160
|
|
|
153
161
|
if cc_change < 0:
|
|
154
162
|
for _ in range(abs(cc_change)):
|
|
155
|
-
|
|
163
|
+
supervisor_queues[cycle(proc_len, backwards=True)].put((Action.KILL_ONE,))
|
|
156
164
|
time.sleep(ramp_interval)
|
|
157
165
|
|
|
158
166
|
|
|
@@ -183,8 +191,8 @@ def run(
|
|
|
183
191
|
_stats_received = stats_received
|
|
184
192
|
|
|
185
193
|
# notify all Supervisors to quit
|
|
186
|
-
for q in
|
|
187
|
-
q.put(
|
|
194
|
+
for q in supervisor_queues.values():
|
|
195
|
+
q.put((Action.POISON_PILL,))
|
|
188
196
|
|
|
189
197
|
# wait for supervisors to quit and drain
|
|
190
198
|
# the to_main_q at the same time to avoid locking
|
|
@@ -339,10 +347,8 @@ def run(
|
|
|
339
347
|
|
|
340
348
|
to_main_q = mp.Queue()
|
|
341
349
|
|
|
342
|
-
global
|
|
350
|
+
global supervisor_queues
|
|
343
351
|
global supervisors
|
|
344
|
-
supervisors = {}
|
|
345
|
-
queues = {}
|
|
346
352
|
|
|
347
353
|
# start a separate thread for messages coming in via the pipe
|
|
348
354
|
# echo 5 > dbworkload.pipe # create 5 more connections
|
|
@@ -350,7 +356,7 @@ def run(
|
|
|
350
356
|
target=listen_to_pipe,
|
|
351
357
|
daemon=True,
|
|
352
358
|
args=(
|
|
353
|
-
|
|
359
|
+
supervisor_queues,
|
|
354
360
|
0,
|
|
355
361
|
procs,
|
|
356
362
|
None,
|
|
@@ -360,12 +366,12 @@ def run(
|
|
|
360
366
|
|
|
361
367
|
# launch supervisors in a dedicated OS process
|
|
362
368
|
for x in range(procs):
|
|
363
|
-
|
|
369
|
+
supervisor_queues[x] = mp.Queue()
|
|
364
370
|
supervisors[x] = mp.Process(
|
|
365
371
|
target=supervisor,
|
|
366
372
|
args=(
|
|
367
373
|
to_main_q,
|
|
368
|
-
|
|
374
|
+
supervisor_queues[x],
|
|
369
375
|
log_level,
|
|
370
376
|
conn_info,
|
|
371
377
|
driver,
|
|
@@ -394,7 +400,6 @@ def run(
|
|
|
394
400
|
current_proc = -1
|
|
395
401
|
current_cc = 0
|
|
396
402
|
thread_id = 0
|
|
397
|
-
pause_for_ramp_time = 0
|
|
398
403
|
|
|
399
404
|
iterations_per_thread = None
|
|
400
405
|
if iterations:
|
|
@@ -424,13 +429,17 @@ def run(
|
|
|
424
429
|
f"Starting schedule {i+1}/{len(schedule)}: {cc=}, {max_rate=}, {ramp_time=}, {dur=}"
|
|
425
430
|
)
|
|
426
431
|
|
|
432
|
+
pause_for_ramp_time = time.time() + 3 * FREQUENCY
|
|
433
|
+
exhaust_warning = 0
|
|
434
|
+
think_time = 0
|
|
435
|
+
|
|
427
436
|
# always make sure that a duration is specified, even if none was passed
|
|
428
437
|
# in which case it defaults to infinite
|
|
429
438
|
end_schedule_time = time.time() + dur if dur else float("inf")
|
|
430
439
|
|
|
431
440
|
# if max_rate was set instead of concurrency
|
|
432
441
|
# and current_cc = 0,
|
|
433
|
-
# start the workload with
|
|
442
|
+
# start the workload with 10 threads so that dbworkload
|
|
434
443
|
# has stats to measure on for adding/removing threads
|
|
435
444
|
# as part of the calculations for maintaining
|
|
436
445
|
# the desired max_rate
|
|
@@ -439,23 +448,23 @@ def run(
|
|
|
439
448
|
target=launch_or_kill_workers,
|
|
440
449
|
daemon=True,
|
|
441
450
|
args=(
|
|
442
|
-
|
|
451
|
+
supervisor_queues,
|
|
443
452
|
ramp_time,
|
|
444
|
-
|
|
453
|
+
10,
|
|
445
454
|
procs,
|
|
446
455
|
iterations_per_thread,
|
|
447
456
|
concurrency,
|
|
448
457
|
),
|
|
449
458
|
).start()
|
|
450
459
|
|
|
451
|
-
current_cc =
|
|
460
|
+
current_cc = 10
|
|
452
461
|
|
|
453
462
|
if not max_rate:
|
|
454
463
|
Thread(
|
|
455
464
|
target=launch_or_kill_workers,
|
|
456
465
|
daemon=True,
|
|
457
466
|
args=(
|
|
458
|
-
|
|
467
|
+
supervisor_queues,
|
|
459
468
|
ramp_time,
|
|
460
469
|
cc - current_cc,
|
|
461
470
|
procs,
|
|
@@ -518,49 +527,131 @@ def run(
|
|
|
518
527
|
if max_rate and report:
|
|
519
528
|
current_rate = report[0][6] # __cycle__ period_ops/s
|
|
520
529
|
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
530
|
+
if time.time() > pause_for_ramp_time:
|
|
531
|
+
|
|
532
|
+
if current_rate < max_rate * 0.99:
|
|
533
|
+
if think_time > 0:
|
|
534
|
+
|
|
535
|
+
think_time = (
|
|
536
|
+
math.floor(
|
|
537
|
+
think_time * current_rate / max_rate * 1000
|
|
538
|
+
)
|
|
539
|
+
/ 1000
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
pause_for_ramp_time = time.time() + 3 * FREQUENCY
|
|
543
|
+
|
|
544
|
+
logger.info(
|
|
545
|
+
f"Calculating max_rate: {max_rate=} {current_rate=}, {current_cc=} {think_time=}"
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
else:
|
|
549
|
+
if exhaust_warning > 3:
|
|
550
|
+
logger.warning("Pointless to add any more threads")
|
|
551
|
+
else:
|
|
552
|
+
|
|
553
|
+
# approximate how many threads are needed to get
|
|
554
|
+
# to the desired max_rate given the current QPS rate
|
|
555
|
+
# and current thread count
|
|
556
|
+
# increase by 5%
|
|
557
|
+
extrapolated_cc = int(
|
|
558
|
+
max_rate / (current_rate / current_cc) * 1.05
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
exhaust_warning += 1
|
|
562
|
+
|
|
563
|
+
change = max(0, extrapolated_cc - current_cc)
|
|
564
|
+
|
|
565
|
+
Thread(
|
|
566
|
+
target=launch_or_kill_workers,
|
|
567
|
+
daemon=True,
|
|
568
|
+
args=(
|
|
569
|
+
supervisor_queues,
|
|
570
|
+
ramp_time,
|
|
571
|
+
change,
|
|
572
|
+
procs,
|
|
573
|
+
iterations_per_thread,
|
|
574
|
+
concurrency,
|
|
575
|
+
),
|
|
576
|
+
).start()
|
|
577
|
+
|
|
578
|
+
# give enough time for newly created threads to settle
|
|
579
|
+
# before new calculations are performed
|
|
580
|
+
pause_for_ramp_time = (
|
|
581
|
+
time.time() + ramp_time + 6 * FREQUENCY
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
logger.info(
|
|
585
|
+
f"Calculating max_rate: {max_rate=} {current_rate=}, {current_cc=} {change=}"
|
|
586
|
+
)
|
|
587
|
+
current_cc += change
|
|
588
|
+
|
|
589
|
+
# ramp_time is only considered for reaching the desired max_rate.
|
|
590
|
+
# For adjustments over time, we want the changes to happen immediately
|
|
591
|
+
# and not smoothed out over the initial ramp_time value
|
|
592
|
+
ramp_time = 0
|
|
593
|
+
|
|
594
|
+
for q in supervisor_queues.values():
|
|
595
|
+
q.put((Action.THINK_TIME, think_time))
|
|
596
|
+
|
|
597
|
+
elif current_rate * 0.99 > max_rate:
|
|
598
|
+
if think_time > 1:
|
|
599
|
+
# pointless to add more time, remove threads instead
|
|
600
|
+
|
|
601
|
+
# decrease count by 5%
|
|
602
|
+
change = int(current_cc * -0.05)
|
|
603
|
+
|
|
604
|
+
Thread(
|
|
605
|
+
target=launch_or_kill_workers,
|
|
606
|
+
daemon=True,
|
|
607
|
+
args=(
|
|
608
|
+
supervisor_queues,
|
|
609
|
+
ramp_time,
|
|
610
|
+
change,
|
|
611
|
+
procs,
|
|
612
|
+
iterations_per_thread,
|
|
613
|
+
concurrency,
|
|
614
|
+
),
|
|
615
|
+
).start()
|
|
616
|
+
|
|
617
|
+
# give enough time for newly created threads to settle
|
|
618
|
+
# before new calculations are performed
|
|
619
|
+
pause_for_ramp_time = (
|
|
620
|
+
time.time() + ramp_time + 6 * FREQUENCY
|
|
621
|
+
)
|
|
622
|
+
|
|
623
|
+
logger.info(
|
|
624
|
+
f"Calculating max_rate: {max_rate=} {current_rate=}, {current_cc=} {change=}"
|
|
625
|
+
)
|
|
626
|
+
current_cc += change
|
|
627
|
+
|
|
628
|
+
# ramp_time is only considered for reaching the desired max_rate.
|
|
629
|
+
# For adjustments over time, we want the changes to happen immediately
|
|
630
|
+
# and not smoothed out over the initial ramp_time value
|
|
631
|
+
ramp_time = 0
|
|
632
|
+
think_time = 0
|
|
633
|
+
|
|
634
|
+
else:
|
|
635
|
+
# add think_time to slow it down a bit
|
|
636
|
+
|
|
637
|
+
if think_time == 0:
|
|
638
|
+
|
|
639
|
+
think_time = round(
|
|
640
|
+
0.01 * current_rate / max_rate, 4
|
|
641
|
+
)
|
|
642
|
+
else:
|
|
643
|
+
think_time = round(
|
|
644
|
+
think_time * current_rate / max_rate, 4
|
|
645
|
+
)
|
|
646
|
+
|
|
647
|
+
pause_for_ramp_time = time.time() + 3 * FREQUENCY
|
|
648
|
+
|
|
649
|
+
logger.info(
|
|
650
|
+
f"Calculating max_rate: {max_rate=} {current_rate=}, {current_cc=} {think_time=}"
|
|
651
|
+
)
|
|
652
|
+
|
|
653
|
+
for q in supervisor_queues.values():
|
|
654
|
+
q.put((Action.THINK_TIME, think_time))
|
|
564
655
|
|
|
565
656
|
centroids = stats.get_centroids()
|
|
566
657
|
|
|
@@ -612,53 +703,60 @@ def supervisor(
|
|
|
612
703
|
logger.setLevel(log_level)
|
|
613
704
|
logger.debug(f"Supervisor-{id} started")
|
|
614
705
|
|
|
615
|
-
|
|
706
|
+
worker_threads: list[Thread] = []
|
|
616
707
|
from_proc_q = mp.Queue()
|
|
617
708
|
|
|
618
709
|
# capture KeyboardInterrupt and do nothing
|
|
619
710
|
signal.signal(signal.SIGINT, signal.SIG_IGN)
|
|
620
711
|
|
|
712
|
+
global think_time
|
|
713
|
+
think_time = 0
|
|
714
|
+
|
|
621
715
|
while True:
|
|
622
716
|
msg = from_main_q.get(block=True)
|
|
623
717
|
|
|
624
|
-
|
|
625
|
-
|
|
718
|
+
match msg[0]:
|
|
719
|
+
case Action.POISON_PILL: # poison pill
|
|
720
|
+
logger.debug(f"Supervisor-{id} terminating...")
|
|
626
721
|
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
722
|
+
# wait for Threads to return before
|
|
723
|
+
# letting the Supervisor MainThread return
|
|
724
|
+
for x in worker_threads:
|
|
725
|
+
if x.is_alive():
|
|
726
|
+
from_proc_q.put(Action.POISON_PILL)
|
|
632
727
|
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
728
|
+
for x in worker_threads:
|
|
729
|
+
if x.is_alive():
|
|
730
|
+
x.join()
|
|
636
731
|
|
|
637
|
-
|
|
638
|
-
|
|
732
|
+
logger.debug(f"Supervisor-{id} terminated")
|
|
733
|
+
return
|
|
639
734
|
|
|
640
|
-
|
|
641
|
-
|
|
735
|
+
case Action.KILL_ONE: # kill_one
|
|
736
|
+
from_proc_q.put(Action.POISON_PILL)
|
|
642
737
|
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
738
|
+
case Action.THINK_TIME: # set think_time
|
|
739
|
+
think_time = msg[1]
|
|
740
|
+
|
|
741
|
+
case Action.NEW_WORKER: # add new worker
|
|
742
|
+
t = Thread(
|
|
743
|
+
target=worker,
|
|
744
|
+
daemon=True,
|
|
745
|
+
args=(
|
|
746
|
+
to_main_q,
|
|
747
|
+
from_proc_q,
|
|
748
|
+
log_level,
|
|
749
|
+
conn_info,
|
|
750
|
+
driver,
|
|
751
|
+
workload,
|
|
752
|
+
args,
|
|
753
|
+
conn_duration,
|
|
754
|
+
offset,
|
|
755
|
+
*msg[1],
|
|
756
|
+
),
|
|
757
|
+
)
|
|
758
|
+
t.start()
|
|
759
|
+
worker_threads.append(t)
|
|
662
760
|
|
|
663
761
|
|
|
664
762
|
def worker(
|
|
@@ -795,6 +893,10 @@ def worker(
|
|
|
795
893
|
|
|
796
894
|
ws.add_latency_measurement("__cycle__", time.time() - cycle_start)
|
|
797
895
|
|
|
896
|
+
if think_time > 0:
|
|
897
|
+
time.sleep(think_time)
|
|
898
|
+
ws.add_latency_measurement("__think_time__", think_time)
|
|
899
|
+
|
|
798
900
|
if to_main_q.full():
|
|
799
901
|
logger.error("=========== Q FULL!!!! ======================")
|
|
800
902
|
if time.time() >= stat_time:
|
|
@@ -871,6 +973,7 @@ def listen_to_pipe(queues, ramp_time, procs, iterations_per_thread, concurrency)
|
|
|
871
973
|
|
|
872
974
|
|
|
873
975
|
def log_and_sleep(e: Exception):
|
|
976
|
+
raise e
|
|
874
977
|
logger.error(f"error_type={e.__class__.__name__}, msg={e}")
|
|
875
978
|
logger.info("Sleeping for %s seconds" % (DEFAULT_SLEEP))
|
|
876
979
|
time.sleep(DEFAULT_SLEEP)
|
|
@@ -48,10 +48,19 @@ NOT_NULL_MAX = 40
|
|
|
48
48
|
|
|
49
49
|
logger = logging.getLogger("dbworkload")
|
|
50
50
|
|
|
51
|
+
from enum import IntEnum
|
|
52
|
+
|
|
51
53
|
from prometheus_client.core import REGISTRY, HistogramMetricFamily
|
|
52
54
|
from prometheus_client.registry import Collector
|
|
53
55
|
|
|
54
56
|
|
|
57
|
+
class Action(IntEnum):
|
|
58
|
+
KILL_ONE = 7
|
|
59
|
+
THINK_TIME = 2
|
|
60
|
+
POISON_PILL = 9
|
|
61
|
+
NEW_WORKER = 1
|
|
62
|
+
|
|
63
|
+
|
|
55
64
|
class Stats:
|
|
56
65
|
"""Print workload stats
|
|
57
66
|
and export the stats as Prometheus endpoints
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "dbworkload"
|
|
3
|
-
version = "0.9.
|
|
3
|
+
version = "0.9.2.dev1"
|
|
4
4
|
description = "Workload framework"
|
|
5
5
|
authors = ["Fabio Ghirardello"]
|
|
6
6
|
license = "GPLv3+"
|
|
@@ -17,7 +17,7 @@ classifiers = [
|
|
|
17
17
|
dbworkload = 'dbworkload.cli.main:app'
|
|
18
18
|
|
|
19
19
|
[tool.poetry.dependencies]
|
|
20
|
-
python = "^3.
|
|
20
|
+
python = "^3.11"
|
|
21
21
|
pandas = "*"
|
|
22
22
|
tabulate = "*"
|
|
23
23
|
numpy = "*"
|
|
@@ -51,6 +51,11 @@ mongo = ["pymongo"]
|
|
|
51
51
|
cassandra = ["cassandra-driver"]
|
|
52
52
|
spanner = ["google-cloud-spanner"]
|
|
53
53
|
|
|
54
|
+
[tool.poetry.group.dev.dependencies]
|
|
55
|
+
mkdocs = "^1.6.1"
|
|
56
|
+
mkdocs-material = "^9.6.14"
|
|
57
|
+
mkdocs-click = "^0.9.0"
|
|
58
|
+
|
|
54
59
|
[build-system]
|
|
55
60
|
requires = ["poetry-core"]
|
|
56
61
|
build-backend = "poetry.core.masonry.api"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|