diff --git a/docs/source/conf.py b/docs/source/conf.py index bba8005..b78e2d2 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -1,7 +1,7 @@ project = 'lightweight-charts-python' copyright = '2023, louisnw' author = 'louisnw' -release = '1.0.14.2' +release = '1.0.14.4' extensions = ["myst_parser"] diff --git a/docs/source/docs.md b/docs/source/docs.md index 2d4ee4d..9f1dbdf 100644 --- a/docs/source/docs.md +++ b/docs/source/docs.md @@ -307,6 +307,29 @@ if __name__ == '__main__': ```{important} This method should be called after the chart window has loaded. ``` +___ + +### `add_hotkey` +`modifier: 'ctrl'/'shift'/'alt'/'meta'` | `key: str/int/tuple` | `method: object` + +Adds a global hotkey to the chart window, which will execute the method or function given. + +When using a number in `key`, it should be given as an integer. If multiple key commands are needed for the same function, you can pass a tuple to `key`. For example: + +```python +def place_buy_order(key): + print(f'Buy {key} shares.') + + +def place_sell_order(key): + print(f'Sell all shares, because I pressed {key}.') + + +chart.add_hotkey('shift', (1, 2, 3), place_buy_order) +chart.add_hotkey('shift', 'X', place_sell_order) +``` + + ___ @@ -436,19 +459,19 @@ ___ ## Callbacks -The `Chart` object allows for asyncronous callbacks to be passed back to python when using the `show_async` method, allowing for more sophisticated chart layouts including searching, timeframe selectors, and text boxes. +The `Chart` object allows for asynchronous and synchronous callbacks to be passed back to python when using the `show_async` method, allowing for more sophisticated chart layouts including searching, timeframe selectors, and text boxes. [`QtChart`](#qtchart) and [`WxChart`](#wxchart) can also use callbacks, however they use their respective event loops to emit callbacks rather than asyncio. A variety of the parameters below should be passed to the Chart upon decaration. -* `api`: The class object that the callbacks will be emitted to (see [How to use Callbacks](#how-to-use-callbacks)). +* `api`: The class object that the fixed callbacks will always be emitted to (see [How to use Callbacks](#how-to-use-callbacks)). * `topbar`: Adds a [TopBar](#topbar) to the `Chart` or `SubChart` and allows use of the `create_switcher` method. * `searchbox`: Adds a search box onto the `Chart` or `SubChart` that is activated by typing. ___ ### How to use Callbacks -Callbacks are emitted to the class given as the `api` parameter shown above. +Fixed Callbacks are emitted to the class given as the `api` parameter shown above. Take a look at this minimal example: @@ -470,6 +493,7 @@ The ID shown above will change depending upon which pane was used to search, due ```{important} * Search callbacks will always be emitted to a method named `on_search` * `API` class methods can be either coroutines or normal methods. +* Non fixed callbacks (switchers, hotkeys) can be methods, coroutines, or regular functions. ``` ___ @@ -569,6 +593,8 @@ The following hotkeys can also be used when the Toolbox is enabled: * Alt+H: Horizontal Line * Alt+R: Ray Line * Meta+Z or Ctrl+Z: Undo + +Drawings can also be deleted by right-clicking on them, which brings up a context menu. ___ ### `save_drawings_under` diff --git a/lightweight_charts/abstract.py b/lightweight_charts/abstract.py index efe1e49..49d445d 100644 --- a/lightweight_charts/abstract.py +++ b/lightweight_charts/abstract.py @@ -332,9 +332,9 @@ class SwitcherWidget(Widget): def __init__(self, topbar, method, *options, default): super().__init__(topbar) self.value = default - self._method = method.__name__ + self._method = str(method) self._chart.run_script(f''' - makeSwitcher({self._chart.id}, {list(options)}, '{default}', '{method.__name__}', + makeSwitcher({self._chart.id}, {list(options)}, '{default}', '{self._method}', '{topbar.active_background_color}', '{topbar.active_text_color}', '{topbar.text_color}', '{topbar.hover_color}') reSize({self._chart.id}) ''') @@ -356,6 +356,7 @@ class TopBar: def __getitem__(self, item): return self._widgets.get(item) def switcher(self, name, method, *options, default=None): + self._chart._methods[str(method)] = method self._widgets[name] = SwitcherWidget(self, method, *options, default=default if default else options[0]) def textbox(self, name, initial_text=''): self._widgets[name] = TextWidget(self, initial_text) @@ -401,7 +402,7 @@ class ToolBox: Exports the current list of drawings to the given file path. """ with open(file_path, 'w+') as f: - json.dump(self._saved_drawings, f) + json.dump(self._saved_drawings, f, indent=4) def _save_drawings(self, drawings): if not self._save_under: @@ -433,6 +434,7 @@ class LWC(SeriesCommon): self._charts = {self.id: self} self._lines = [] self._js_api_code = _js_api_code + self._methods = {} self._return_q = None self._background_color = '#000000' @@ -818,7 +820,7 @@ class LWC(SeriesCommon): canvas.toBlob(function(blob) {{ const reader = new FileReader(); reader.onload = function(event) {{ - {self._js_api_code}(`return__{self.id}__${{event.target.result}}`) + {self._js_api_code}(`return_~_{self.id}_~_${{event.target.result}}`) }}; reader.readAsDataURL(blob); }}) @@ -826,6 +828,21 @@ class LWC(SeriesCommon): serial_data = self._return_q.get() return b64decode(serial_data.split(',')[1]) + def add_hotkey(self, modifier_key: Literal['ctrl', 'alt', 'shift', 'meta'], keys: Union[str, tuple, int], method): + self._methods[str(method)] = method + if not isinstance(keys, tuple): keys = (keys,) + for key in keys: + key_code = 'Key' + key.upper() if isinstance(key, str) else 'Digit' + str(key) + self.run_script(f''' + {self.id}.commandFunctions.unshift((event) => {{ + if (event.{modifier_key + 'Key'} && event.code === '{key_code}') {{ + event.preventDefault() + {self.id}.callbackFunction(`{str(method)}_~_{self.id}_~_{key}`) + return true + }} + else return false + }})''') + def create_subchart(self, volume_enabled: bool = True, position: Literal['left', 'right', 'top', 'bottom'] = 'left', width: float = 0.5, height: float = 0.5, sync: Union[bool, str] = False, dynamic_loading: bool = False, scale_candles_only: bool = False, topbar: bool = False, searchbox: bool = False, toolbox: bool = False): diff --git a/lightweight_charts/chart.py b/lightweight_charts/chart.py index ed8c39f..a140835 100644 --- a/lightweight_charts/chart.py +++ b/lightweight_charts/chart.py @@ -10,7 +10,7 @@ class CallbackAPI: self.emit_q, self.return_q = emit_queue, return_queue def callback(self, message: str): - messages = message.split('__') + messages = message.split('_~_') name, chart_id = messages[:2] args = messages[2:] self.return_q.put(*args) if name == 'return' else self.emit_q.put((name, chart_id, *args)) @@ -92,12 +92,13 @@ class Chart(LWC): if name == 'save_drawings': self._api.chart.toolbox._save_drawings(arg) continue - method = getattr(self._api, name) + fixed_callbacks = ('on_search', 'on_horizontal_line_move') + func = self._methods[name] if name not in fixed_callbacks else getattr(self._api, name) if hasattr(self._api.chart, 'topbar') and (widget := self._api.chart.topbar._widget_with_method(name)): widget.value = arg - await method() if asyncio.iscoroutinefunction(method) else method() + await func() if asyncio.iscoroutinefunction(func) else func() else: - await method(*arg.split(';;;')) if asyncio.iscoroutinefunction(method) else method(arg) + await func(*arg.split(';;;')) if asyncio.iscoroutinefunction(func) else func(*arg.split(';;;')) continue value = self.polygon._q.get() func, args = value[0], value[1:] diff --git a/lightweight_charts/js/callback.js b/lightweight_charts/js/callback.js index c07e871..adf9958 100644 --- a/lightweight_charts/js/callback.js +++ b/lightweight_charts/js/callback.js @@ -60,7 +60,7 @@ function makeSearchBox(chart) { else return false } else if (event.key === 'Enter') { - chart.callbackFunction(`on_search__${chart.id}__${sBox.value}`) + chart.callbackFunction(`on_search_~_${chart.id}_~_${sBox.value}`) searchWindow.style.display = 'none' sBox.value = '' return true @@ -145,7 +145,7 @@ function makeSwitcher(chart, items, activeItem, callbackName, activeBackgroundCo element.style.color = items[index] === item ? 'activeColor' : inactiveColor }); activeItem = item; - chart.callbackFunction(`${callbackName}__${chart.id}__${item}`); + chart.callbackFunction(`${callbackName}_~_${chart.id}_~_${item}`); } chart.topBar.appendChild(switcherElement) makeSeperator(chart.topBar) diff --git a/lightweight_charts/js/funcs.js b/lightweight_charts/js/funcs.js index 82ddf86..e268c84 100644 --- a/lightweight_charts/js/funcs.js +++ b/lightweight_charts/js/funcs.js @@ -367,3 +367,49 @@ function calculateTrendLine(startDate, startValue, endDate, endValue, interval, } return trendData; } + + +if (!window.ContextMenu) { + class ContextMenu { + constructor() { + this.menu = document.createElement('div') + this.menu.style.position = 'absolute' + this.menu.style.zIndex = '10000' + this.menu.style.background = 'rgb(50, 50, 50)' + this.menu.style.color = 'lightgrey' + this.menu.style.display = 'none' + this.menu.style.borderRadius = '5px' + this.menu.style.padding = '3px 3px' + this.menu.style.fontSize = '14px' + this.menu.style.cursor = 'default' + document.body.appendChild(this.menu) + + let closeMenu = (event) => { + if (!this.menu.contains(event.target)) this.menu.style.display = 'none'; + } + + this.onRightClick = (event) => { + event.preventDefault(); + this.menu.style.left = event.clientX + 'px'; + this.menu.style.top = event.clientY + 'px'; + this.menu.style.display = 'block'; + document.removeEventListener('click', closeMenu) + document.addEventListener('click', closeMenu) + } + } + listen(active) { + active ? document.addEventListener('contextmenu', this.onRightClick) : document.removeEventListener('contextmenu', this.onRightClick) + } + menuItem(text, action) { + let elem = document.createElement('div') + elem.innerText = text + elem.style.padding = '0px 10px' + elem.style.borderRadius = '3px' + this.menu.appendChild(elem) + elem.addEventListener('mouseover', (event) => elem.style.backgroundColor = 'rgba(0, 122, 255, 0.3)') + elem.addEventListener('mouseout', (event) => elem.style.backgroundColor = 'transparent') + elem.addEventListener('click', (event) => {action(); this.menu.style.display = 'none'}) + } + } + window.ContextMenu = ContextMenu +} diff --git a/lightweight_charts/js/toolbox.js b/lightweight_charts/js/toolbox.js index 69a1481..b1a8136 100644 --- a/lightweight_charts/js/toolbox.js +++ b/lightweight_charts/js/toolbox.js @@ -57,17 +57,8 @@ if (!window.ToolBox) { let commandZHandler = (toDelete) => { if (!toDelete) return - if ('price' in toDelete) { - if (toDelete.id !== 'toolBox') return commandZHandler(this.drawings[this.drawings.indexOf(toDelete) - 1]) - this.chart.series.removePriceLine(toDelete.line) - } else { - let logical - if (toDelete.ray) logical = this.chart.chart.timeScale().getVisibleLogicalRange() - this.chart.chart.removeSeries(toDelete.line); - if (toDelete.ray) this.chart.chart.timeScale().setVisibleLogicalRange(logical) - } - this.drawings.splice(this.drawings.indexOf(toDelete)) - this.saveDrawings() + if ('price' in toDelete && toDelete.id !== 'toolBox') return commandZHandler(this.drawings[this.drawings.indexOf(toDelete) - 1]) + this.deleteDrawing(toDelete) } this.chart.commandFunctions.push((event) => { if ((event.metaKey || event.ctrlKey) && event.code === 'KeyZ') { @@ -184,15 +175,13 @@ if (!window.ToolBox) { if (!currentTime) return this.chart.chart.subscribeCrosshairMove(crosshairHandlerTrend) - trendLine.data = calculateTrendLine(firstTime, firstPrice, currentTime, currentPrice, this.interval, this.chart, ray) - trendLine.from = trendLine.data[0].time - trendLine.to = trendLine.data[trendLine.data.length - 1].time - + let data = calculateTrendLine(firstTime, firstPrice, currentTime, currentPrice, this.interval, this.chart, ray) + trendLine.from = [data[0].time, data[0].value] + trendLine.to = [data[data.length - 1].time, data[data.length-1].value] if (ray) logical = this.chart.chart.timeScale().getVisibleLogicalRange() - - trendLine.line.setData(trendLine.data) + trendLine.line.setData(data) if (logical) { this.chart.chart.applyOptions({handleScroll: true}) @@ -278,6 +267,11 @@ if (!window.ToolBox) { subscribeHoverMove() { let hoveringOver = null let x, y + + let onClickDelete = () => this.deleteDrawing(contextMenu.drawing) + let contextMenu = new ContextMenu() + contextMenu.menuItem('Delete Drawing', onClickDelete) + let hoverOver = (param) => { if (!param.point || this.makingDrawing) return this.chart.chart.unsubscribeCrosshairMove(hoverOver) @@ -308,10 +302,13 @@ if (!window.ToolBox) { document.addEventListener('mousedown', checkForClick) document.addEventListener('mouseup', checkForRelease) hoveringOver = drawing + contextMenu.listen(true) + contextMenu.drawing = drawing } else if (hoveringOver === drawing) { if (!horizontal && !drawing.ray) drawing.line.setMarkers([]) document.body.style.cursor = this.chart.cursor hoveringOver = null + contextMenu.listen(false) } }) this.chart.chart.subscribeCrosshairMove(hoverOver) @@ -335,10 +332,10 @@ if (!window.ToolBox) { if ('price' in hoveringOver) { originalPrice = hoveringOver.price this.chart.chart.subscribeCrosshairMove(crosshairHandlerHorz) - } else if (Math.abs(this.chart.chart.timeScale().timeToCoordinate(hoveringOver.data[0].time) - x) < 4 && !hoveringOver.ray) { + } else if (Math.abs(this.chart.chart.timeScale().timeToCoordinate(hoveringOver.from[0]) - x) < 4 && !hoveringOver.ray) { clickedEnd = 'first' this.chart.chart.subscribeCrosshairMove(crosshairHandlerTrend) - } else if (Math.abs(this.chart.chart.timeScale().timeToCoordinate(hoveringOver.data[hoveringOver.data.length - 1].time) - x) < 4 && !hoveringOver.ray) { + } else if (Math.abs(this.chart.chart.timeScale().timeToCoordinate(hoveringOver.to[0]) - x) < 4 && !hoveringOver.ray) { clickedEnd = 'last' this.chart.chart.subscribeCrosshairMove(crosshairHandlerTrend) } else { @@ -355,7 +352,7 @@ if (!window.ToolBox) { this.chart.chart.applyOptions({handleScroll: true}) if (hoveringOver && 'price' in hoveringOver && hoveringOver.id !== 'toolBox') { - this.chart.callbackFunction(`on_horizontal_line_move__${this.chart.id}__${hoveringOver.id};;;${hoveringOver.price.toFixed(8)}`); + this.chart.callbackFunction(`on_horizontal_line_move_~_${this.chart.id}_~_${hoveringOver.id};;;${hoveringOver.price.toFixed(8)}`); } hoveringOver = null document.removeEventListener('mousedown', checkForClick) @@ -373,32 +370,31 @@ if (!window.ToolBox) { let priceDiff = priceAtCursor - originalPrice let barsToMove = param.logical - originalIndex - let startBarIndex = this.chart.candleData.findIndex(item => chartTimeToDate(item.time).getTime() === chartTimeToDate(hoveringOver.data[0].time).getTime()) - let endBarIndex = this.chart.candleData.findIndex(item => chartTimeToDate(item.time).getTime() === chartTimeToDate(hoveringOver.data[hoveringOver.data.length - 1].time).getTime()) + let startBarIndex = this.chart.candleData.findIndex(item => chartTimeToDate(item.time).getTime() === chartTimeToDate(hoveringOver.from[0]).getTime()) + let endBarIndex = this.chart.candleData.findIndex(item => chartTimeToDate(item.time).getTime() === chartTimeToDate(hoveringOver.to[0]).getTime()) - let startBar + let startDate let endBar if (hoveringOver.ray) { endBar = this.chart.candleData[startBarIndex + barsToMove] - startBar = hoveringOver.data[hoveringOver.data.length - 1] + startDate = hoveringOver.to[0] } else { - startBar = this.chart.candleData[startBarIndex + barsToMove] + startDate = this.chart.candleData[startBarIndex + barsToMove].time endBar = endBarIndex === -1 ? null : this.chart.candleData[endBarIndex + barsToMove] } - let endDate = endBar ? endBar.time : dateToChartTime(new Date(chartTimeToDate(hoveringOver.data[hoveringOver.data.length - 1].time).getTime() + (barsToMove * this.interval)), this.interval) - let startDate = startBar.time - let startValue = hoveringOver.data[0].value + priceDiff - let endValue = hoveringOver.data[hoveringOver.data.length - 1].value + priceDiff - hoveringOver.data = calculateTrendLine(startDate, startValue, endDate, endValue, this.interval, this.chart, hoveringOver.ray) + let endDate = endBar ? endBar.time : dateToChartTime(new Date(chartTimeToDate(hoveringOver.to[0]).getTime() + (barsToMove * this.interval)), this.interval) + let startValue = hoveringOver.from[1] + priceDiff + let endValue = hoveringOver.to[1] + priceDiff + let data = calculateTrendLine(startDate, startValue, endDate, endValue, this.interval, this.chart, hoveringOver.ray) let logical - if (chartTimeToDate(hoveringOver.data[hoveringOver.data.length - 1].time).getTime() >= chartTimeToDate(this.chart.candleData[this.chart.candleData.length - 1].time).getTime()) { + if (chartTimeToDate(data[data.length - 1].time).getTime() >= chartTimeToDate(this.chart.candleData[this.chart.candleData.length - 1].time).getTime()) { logical = this.chart.chart.timeScale().getVisibleLogicalRange() } - hoveringOver.from = hoveringOver.data[0].time - hoveringOver.to = hoveringOver.data[hoveringOver.data.length - 1].time - hoveringOver.line.setData(hoveringOver.data) + hoveringOver.from = [data[0].time, data[0].value] + hoveringOver.to = [data[data.length - 1].time, data[data.length - 1].value] + hoveringOver.line.setData(data) if (logical) this.chart.chart.timeScale().setVisibleLogicalRange(logical) if (!hoveringOver.ray) { @@ -423,11 +419,11 @@ if (!window.ToolBox) { let [firstTime, firstPrice] = [null, null] if (clickedEnd === 'last') { - firstTime = hoveringOver.data[0].time - firstPrice = hoveringOver.data[0].value + firstTime = hoveringOver.from[0] + firstPrice = hoveringOver.from[1] } else if (clickedEnd === 'first') { - firstTime = hoveringOver.data[hoveringOver.data.length - 1].time - firstPrice = hoveringOver.data[hoveringOver.data.length - 1].value + firstTime = hoveringOver.to[0] + firstPrice = hoveringOver.to[1] } let logical @@ -440,11 +436,11 @@ if (!window.ToolBox) { logical = this.chart.chart.timeScale().getVisibleLogicalRange() } - hoveringOver.data = calculateTrendLine(firstTime, firstPrice, currentTime, currentPrice, this.interval, this.chart) - hoveringOver.line.setData(hoveringOver.data) + let data = calculateTrendLine(firstTime, firstPrice, currentTime, currentPrice, this.interval, this.chart) + hoveringOver.line.setData(data) - hoveringOver.from = hoveringOver.data[0].time - hoveringOver.to = hoveringOver.data[hoveringOver.data.length - 1].time + hoveringOver.from = [data[0].time, data[0].value] + hoveringOver.to = [data[data.length - 1].time, data[data.length - 1].value] if (logical) this.chart.chart.timeScale().setVisibleLogicalRange(logical) hoveringOver.markers = [ @@ -473,15 +469,28 @@ if (!window.ToolBox) { //let logical = this.chart.chart.timeScale().getVisibleLogicalRange() this.drawings.forEach((item) => { if ('price' in item) return - let startDate = dateToChartTime(new Date(Math.round(chartTimeToDate(item.from).getTime() / this.interval) * this.interval), this.interval) - let endDate = dateToChartTime(new Date(Math.round(chartTimeToDate(item.to).getTime() / this.interval) * this.interval), this.interval) - let data = calculateTrendLine(startDate, item.data[0].value, endDate, item.data[item.data.length - 1].value, this.interval, this.chart, item.ray) - if (data.length !== 0) item.data = data + let startDate = dateToChartTime(new Date(Math.round(chartTimeToDate(item.from[0]).getTime() / this.interval) * this.interval), this.interval) + let endDate = dateToChartTime(new Date(Math.round(chartTimeToDate(item.to[0]).getTime() / this.interval) * this.interval), this.interval) + let data = calculateTrendLine(startDate, item.from[1], endDate, item.to[1], this.interval, this.chart, item.ray) item.line.setData(data) }) //this.chart.chart.timeScale().setVisibleLogicalRange(logical) } + deleteDrawing(drawing) { + if ('price' in drawing) { + this.chart.series.removePriceLine(drawing.line) + } + else { + let logical + if (drawing.ray) logical = this.chart.chart.timeScale().getVisibleLogicalRange() + this.chart.chart.removeSeries(drawing.line); + if (drawing.ray) this.chart.chart.timeScale().setVisibleLogicalRange(logical) + } + this.drawings.splice(this.drawings.indexOf(drawing), 1) + this.saveDrawings() + } + clearDrawings() { this.drawings.forEach((item) => { if ('price' in item) this.chart.series.removePriceLine(item.line) @@ -499,7 +508,7 @@ if (!window.ToolBox) { } return value; }); - this.chart.callbackFunction(`save_drawings__${this.chart.id}__${drawingsString}`) + this.chart.callbackFunction(`save_drawings_~_${this.chart.id}_~_${drawingsString}`) } loadDrawings(drawings) { @@ -525,10 +534,9 @@ if (!window.ToolBox) { }, }), }) - let startDate = dateToChartTime(new Date(Math.round(chartTimeToDate(item.from).getTime() / this.interval) * this.interval), this.interval) - let endDate = dateToChartTime(new Date(Math.round(chartTimeToDate(item.to).getTime() / this.interval) * this.interval), this.interval) - let data = calculateTrendLine(startDate, item.data[0].value, endDate, item.data[item.data.length - 1].value, this.interval, this.chart, item.ray) - if (data.length !== 0) item.data = data + let startDate = dateToChartTime(new Date(Math.round(chartTimeToDate(item.from[0]).getTime() / this.interval) * this.interval), this.interval) + let endDate = dateToChartTime(new Date(Math.round(chartTimeToDate(item.to[0]).getTime() / this.interval) * this.interval), this.interval) + let data = calculateTrendLine(startDate, item.from[1], endDate, item.to[1], this.interval, this.chart, item.ray) item.line.setData(data) } }) diff --git a/lightweight_charts/widgets.py b/lightweight_charts/widgets.py index 0b7fa03..f6a3de5 100644 --- a/lightweight_charts/widgets.py +++ b/lightweight_charts/widgets.py @@ -33,16 +33,17 @@ from lightweight_charts.abstract import LWC, JS def _widget_message(chart, string): - messages = string.split('__') + messages = string.split('_~_') name, chart_id = messages[:2] arg = messages[2] chart.api.chart = chart._charts[chart_id] - method = getattr(chart.api, name) - if widget := chart.api.chart.topbar._widget_with_method(name): + fixed_callbacks = ('on_search', 'on_horizontal_line_move') + func = chart._methods[name] if name not in fixed_callbacks else getattr(chart._api, name) + if hasattr(chart._api.chart, 'topbar') and (widget := chart._api.chart.topbar._widget_with_method(name)): widget.value = arg - asyncio.create_task(getattr(chart.api, name)()) if iscoroutinefunction(method) else method() + asyncio.create_task(func()) if asyncio.iscoroutinefunction(func) else func() else: - asyncio.create_task(getattr(chart.api, name)(arg)) if iscoroutinefunction(method) else method(arg) + asyncio.create_task(func(*arg.split(';;;'))) if asyncio.iscoroutinefunction(func) else func(*arg.split(';;;')) class WxChart(LWC): diff --git a/setup.py b/setup.py index 4641c02..fcb1fe9 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ with open('README.md', 'r', encoding='utf-8') as f: setup( name='lightweight_charts', - version='1.0.14.2', + version='1.0.14.4', packages=find_packages(), python_requires='>=3.8', install_requires=[