Compare commits
	
		
			2 Commits
		
	
	
		
			ecbb8661ce
			...
			d3917b1f58
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| d3917b1f58 | |||
| 04880b7724 | 
							
								
								
									
										15
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										15
									
								
								README.md
									
									
									
									
									
								
							| @ -76,20 +76,21 @@ Lamb treats each λ expression as a binary tree. Variable binding and reduction | ||||
|  | ||||
| ## Todo (pre-release): | ||||
|  - Prettier colors | ||||
|  - Prevent macro-chaining recursion | ||||
|  - step-by-step reduction | ||||
|  - Full-reduce option (expand all macros) | ||||
|  - PyPi package | ||||
|  - Cleanup warnings | ||||
|  - Preprocess method: bind, macros to free, etc | ||||
|  - History queue | ||||
|  - Truncate long expressions in warnings | ||||
|  - Prevent macro-chaining recursion | ||||
|  - Full-reduce option (expand all macros) | ||||
|  - step-by-step reduction | ||||
|  - Cleanup files | ||||
|  - PyPi package | ||||
|  | ||||
|  | ||||
| ## Todo: | ||||
|  - History queue + command indexing | ||||
|  - Show history command | ||||
|  - Better class mutation: when is a node no longer valid? | ||||
|  - Loop detection | ||||
|  - Command-line options (load a file, run a set of commands) | ||||
|  - $\alpha$-equivalence check | ||||
|  - Command-line options (load a file, run a set of commands) | ||||
|  - Unchurch macro: make church numerals human-readable | ||||
|  - Syntax highlighting: parenthesis, bound variables, macros, etc | ||||
							
								
								
									
										208
									
								
								lamb/node.py
									
									
									
									
									
								
							
							
						
						
									
										208
									
								
								lamb/node.py
									
									
									
									
									
								
							| @ -20,9 +20,6 @@ class ReductionType(enum.Enum): | ||||
| 	# We turned a church numeral into an expression | ||||
| 	AUTOCHURCH		= enum.auto() | ||||
|  | ||||
| 	# We replaced a macro with a free variable. | ||||
| 	MACRO_TO_FREE	= enum.auto() | ||||
|  | ||||
| 	# We applied a function. | ||||
| 	# This is the only type of "formal" reduction step. | ||||
| 	FUNCTION_APPLY	= enum.auto() | ||||
| @ -50,27 +47,32 @@ class TreeWalker: | ||||
| 	def __init__(self, expr): | ||||
| 		self.expr = expr | ||||
| 		self.ptr = expr | ||||
| 		self.first_step = True | ||||
| 		self.from_side = Direction.UP | ||||
|  | ||||
| 	def __iter__(self): | ||||
| 		return self | ||||
|  | ||||
| 	def __next__(self): | ||||
| 		# This could be implemented without checking the node type, | ||||
| 		# but there's no reason to do that. | ||||
| 		# Maybe later? | ||||
|  | ||||
| 		if self.ptr is self.expr.parent: | ||||
| 			raise StopIteration | ||||
|  | ||||
| 		out = self.ptr | ||||
| 		out_side = self.from_side | ||||
| 		if isinstance(self.ptr, EndNode): | ||||
| 		if self.first_step: | ||||
| 			self.first_step = False | ||||
| 			return self.from_side, self.ptr | ||||
|  | ||||
| 		if isinstance(self.ptr, Root): | ||||
| 			if self.from_side == Direction.UP: | ||||
| 				self.from_side, self.ptr = self.ptr.go_left() | ||||
| 		elif isinstance(self.ptr, EndNode): | ||||
| 			self.from_side, self.ptr = self.ptr.go_up() | ||||
|  | ||||
| 		elif isinstance(self.ptr, Func): | ||||
| 			if self.from_side == Direction.UP: | ||||
| 				self.from_side, self.ptr = self.ptr.go_left() | ||||
| 			elif self.from_side == Direction.LEFT: | ||||
| 				self.from_side, self.ptr = self.ptr.go_up() | ||||
|  | ||||
| 		elif isinstance(self.ptr, Call): | ||||
| 			if self.from_side == Direction.UP: | ||||
| 				self.from_side, self.ptr = self.ptr.go_left() | ||||
| @ -78,11 +80,18 @@ class TreeWalker: | ||||
| 				self.from_side, self.ptr = self.ptr.go_right() | ||||
| 			elif self.from_side == Direction.RIGHT: | ||||
| 				self.from_side, self.ptr = self.ptr.go_up() | ||||
|  | ||||
| 		else: | ||||
| 			raise TypeError(f"I don't know how to iterate a {type(self.ptr)}") | ||||
|  | ||||
| 		return out_side, out | ||||
| 		# Stop conditions | ||||
| 		if isinstance(self.expr, Root): | ||||
| 			if self.ptr is self.expr: | ||||
| 				raise StopIteration | ||||
| 		else: | ||||
| 			if self.ptr is self.expr.parent: | ||||
| 				raise StopIteration | ||||
|  | ||||
| 		return self.from_side, self.ptr | ||||
|  | ||||
| class Node: | ||||
| 	""" | ||||
| @ -93,11 +102,11 @@ class Node: | ||||
| 	def __init__(self): | ||||
| 		# The node this one is connected to. | ||||
| 		# None if this is the top objects. | ||||
| 		self.parent: Node | None = None | ||||
| 		self.parent: Node = None # type: ignore | ||||
|  | ||||
| 		# What direction this is relative to the parent. | ||||
| 		# Left of Right. | ||||
| 		self.parent_side: Direction | None = None | ||||
| 		self.parent_side: Direction = None # type: ignore | ||||
|  | ||||
| 		# Left and right nodes, None if empty | ||||
| 		self._left: Node | None = None | ||||
| @ -220,12 +229,6 @@ class Node: | ||||
| 		""" | ||||
| 		return print_node(self, export = True) | ||||
|  | ||||
| 	def bind_variables(self, *, ban_macro_name = None): | ||||
| 		return bind_variables( | ||||
| 			self, | ||||
| 			ban_macro_name = ban_macro_name | ||||
| 		) | ||||
|  | ||||
| 	def set_runner(self, runner): | ||||
| 		for s, n in self: | ||||
| 			if s == Direction.UP: | ||||
| @ -279,9 +282,17 @@ class Macro(ExpandableEndNode): | ||||
|  | ||||
| 	def expand(self) -> tuple[ReductionType, Node]: | ||||
| 		if self.name in self.runner.macro_table: | ||||
| 			return ReductionType.MACRO_EXPAND, clone(self.runner.macro_table[self.name]) | ||||
| 			# The element in the macro table will be a Root node, | ||||
| 			# so we clone its left element. | ||||
| 			return ( | ||||
| 				ReductionType.MACRO_EXPAND, | ||||
| 				clone(self.runner.macro_table[self.name].left) | ||||
| 			) | ||||
| 		else: | ||||
| 			return ReductionType.MACRO_TO_FREE, FreeVar(self.name, runner = self.runner) | ||||
| 			raise Exception(f"Macro {self.name} is not defined") | ||||
|  | ||||
| 	def to_freevar(self): | ||||
| 		return FreeVar(self.name, runner = self.runner) | ||||
|  | ||||
| 	def copy(self): | ||||
| 		return Macro(self.name, runner = self.runner) | ||||
| @ -342,7 +353,10 @@ class History(ExpandableEndNode): | ||||
| 	def expand(self) -> tuple[ReductionType, Node]: | ||||
| 		if len(self.runner.history) == 0: | ||||
| 			raise ReductionError(f"There isn't any history to reference.") | ||||
| 		return ReductionType.HIST_EXPAND, clone(self.runner.history[-1]) | ||||
| 		# .left is VERY important! | ||||
| 		# self.runner.history will contain Root nodes, | ||||
| 		# and we don't want those *inside* our tree. | ||||
| 		return ReductionType.HIST_EXPAND, clone(self.runner.history[-1].left) | ||||
|  | ||||
| 	def copy(self): | ||||
| 		return History(runner = self.runner) | ||||
| @ -401,6 +415,23 @@ class Func(Node): | ||||
| 	def copy(self): | ||||
| 		return Func(self.input, None, runner = self.runner) # type: ignore | ||||
|  | ||||
| class Root(Node): | ||||
| 	""" | ||||
| 	Root node. | ||||
| 	Used at the top of an expression. | ||||
| 	""" | ||||
|  | ||||
| 	def __init__(self, left: Node, *, runner = None) -> None: | ||||
| 		super().__init__() | ||||
| 		self.left: Node = left | ||||
| 		self.runner = runner # type: ignore | ||||
|  | ||||
| 	def __repr__(self): | ||||
| 		return f"<Root {self.left!r}>" | ||||
|  | ||||
| 	def copy(self): | ||||
| 		return Root(None, runner = self.runner) # type: ignore | ||||
|  | ||||
| class Call(Node): | ||||
| 	@staticmethod | ||||
| 	def from_parse(results): | ||||
| @ -498,7 +529,7 @@ def clone(node: Node): | ||||
| 		if isinstance(ptr, EndNode): | ||||
| 			from_side, ptr = ptr.go_up() | ||||
| 			_, out_ptr = out_ptr.go_up() | ||||
| 		elif isinstance(ptr, Func): | ||||
| 		elif isinstance(ptr, Func) or isinstance(ptr, Root): | ||||
| 			if from_side == Direction.UP: | ||||
| 				from_side, ptr = ptr.go_left() | ||||
| 				out_ptr.set_side(ptr.parent_side, ptr.copy()) | ||||
| @ -523,9 +554,17 @@ def clone(node: Node): | ||||
| 			break | ||||
| 	return out | ||||
|  | ||||
| def bind_variables(node: Node, *, ban_macro_name = None) -> dict: | ||||
| 	if not isinstance(node, Node): | ||||
| 		raise TypeError(f"I don't know what to do with a {type(node)}") | ||||
| def prepare(root: Root, *, ban_macro_name = None) -> dict: | ||||
| 	""" | ||||
| 	Prepare an expression for expansion. | ||||
| 	This will does the following: | ||||
| 		- Binds variables | ||||
| 		- Turns unbound macros into free variables | ||||
| 		- Generates warnings | ||||
| 	""" | ||||
|  | ||||
| 	if not isinstance(root, Root): | ||||
| 		raise TypeError(f"I don't know what to do with a {type(root)}") | ||||
|  | ||||
| 	bound_variables = {} | ||||
|  | ||||
| @ -534,7 +573,8 @@ def bind_variables(node: Node, *, ban_macro_name = None) -> dict: | ||||
| 		"free_variables": set() | ||||
| 	} | ||||
|  | ||||
| 	for s, n in node: | ||||
| 	it = iter(root) | ||||
| 	for s, n in it: | ||||
| 		if isinstance(n, History): | ||||
| 			output["has_history"] = True | ||||
|  | ||||
| @ -544,9 +584,26 @@ def bind_variables(node: Node, *, ban_macro_name = None) -> dict: | ||||
| 			if (n.name == ban_macro_name) and (ban_macro_name is not None): | ||||
| 				raise ReductionError("Macro cannot reference self") | ||||
|  | ||||
| 			if n.name not in node.runner.macro_table: | ||||
| 				output["free_variables"].add(n.name) | ||||
| 			# Bind variables | ||||
| 			if n.name in bound_variables: | ||||
| 				n.parent.set_side( | ||||
| 					n.parent_side, | ||||
| 					clone(bound_variables[n.name]) | ||||
| 				) | ||||
| 				it.ptr = n.parent.get_side(n.parent_side) | ||||
|  | ||||
| 			# Turn undefined macros into free variables | ||||
| 			elif n.name not in root.runner.macro_table: | ||||
| 				output["free_variables"].add(n.name) | ||||
| 				n.parent.set_side( | ||||
| 					n.parent_side, | ||||
| 					n.to_freevar() | ||||
| 				) | ||||
| 				it.ptr = n.parent.get_side(n.parent_side) | ||||
|  | ||||
|  | ||||
| 		# Save bound variables when we enter a function's sub-tree, | ||||
| 		# delete them when we exit it. | ||||
| 		elif isinstance(n, Func): | ||||
| 			if s == Direction.UP: | ||||
| 				# Add this function's input to the table of bound variables. | ||||
| @ -557,23 +614,9 @@ def bind_variables(node: Node, *, ban_macro_name = None) -> dict: | ||||
| 					bound_variables[n.input.name] = Bound(n.input.name) | ||||
| 					n.input = bound_variables[n.input.name] | ||||
|  | ||||
| 				# If output is a macro, swap it with a bound variable. | ||||
| 				if isinstance(n.left, Macro): | ||||
| 					if n.left.name in bound_variables: | ||||
| 						n.left = clone(bound_variables[n.left.name]) | ||||
|  | ||||
| 			elif s == Direction.LEFT: | ||||
| 				del bound_variables[n.input.name] | ||||
|  | ||||
| 		elif isinstance(n, Call): | ||||
| 			if s == Direction.UP: | ||||
| 				# Bind macros | ||||
| 				if isinstance(n.left, Macro): | ||||
| 					if n.left.name in bound_variables: | ||||
| 						n.left = clone(bound_variables[n.left.name]) | ||||
| 				if isinstance(n.right, Macro): | ||||
| 					if n.right.name in bound_variables: | ||||
| 						n.right = clone(bound_variables[n.right.name]) | ||||
| 	return output | ||||
|  | ||||
| # Apply a function. | ||||
| @ -589,22 +632,18 @@ def call_func(fn: Func, arg: Node): | ||||
| 	return fn.left | ||||
|  | ||||
| # Do a single reduction step | ||||
| def reduce(node: Node) -> tuple[ReductionType, Node]: | ||||
| 	if not isinstance(node, Node): | ||||
| 		raise TypeError(f"I can't reduce a {type(node)}") | ||||
| def reduce(root: Root) -> tuple[ReductionType, Root]: | ||||
| 	if not isinstance(root, Root): | ||||
| 		raise TypeError(f"I can't reduce a {type(root)}") | ||||
|  | ||||
| 	out = node | ||||
| 	out = root | ||||
| 	for s, n in out: | ||||
| 		if isinstance(n, Call) and (s == Direction.UP): | ||||
| 			if isinstance(n.left, Func): | ||||
| 				if n.parent is None: | ||||
| 					out = call_func(n.left, n.right) | ||||
| 					out._set_parent(None, None) | ||||
| 				else: | ||||
| 					n.parent.set_side( | ||||
| 						n.parent_side, # type: ignore | ||||
| 						call_func(n.left, n.right) | ||||
| 					) | ||||
| 				n.parent.set_side( | ||||
| 					n.parent_side, # type: ignore | ||||
| 					call_func(n.left, n.right) | ||||
| 				) | ||||
|  | ||||
| 				return ReductionType.FUNCTION_APPLY, out | ||||
|  | ||||
| @ -614,7 +653,7 @@ def reduce(node: Node) -> tuple[ReductionType, Node]: | ||||
| 	return ReductionType.NOTHING, out | ||||
|  | ||||
|  | ||||
| def expand(node: Node, *, force_all = False) -> tuple[int, Node]: | ||||
| def expand(root: Root, *, force_all = False) -> tuple[int, Root]: | ||||
| 	""" | ||||
| 	Expands expandable nodes in the given tree. | ||||
|  | ||||
| @ -625,50 +664,25 @@ def expand(node: Node, *, force_all = False) -> tuple[int, Node]: | ||||
| 	ExpandableEndnodes. | ||||
| 	""" | ||||
|  | ||||
| 	if not isinstance(node, Node): | ||||
| 		raise TypeError(f"I don't know what to do with a {type(node)}") | ||||
| 	if not isinstance(root, Root): | ||||
| 		raise TypeError(f"I don't know what to do with a {type(root)}") | ||||
|  | ||||
| 	out = clone(node) | ||||
| 	ptr = out | ||||
| 	from_side = Direction.UP | ||||
| 	out = root | ||||
| 	macro_expansions = 0 | ||||
|  | ||||
| 	while True: | ||||
| 	it = iter(root) | ||||
| 	for s, n in it: | ||||
| 		if ( | ||||
| 				isinstance(ptr, ExpandableEndNode) and | ||||
| 				(force_all or ptr.always_expand) | ||||
| 				isinstance(n, ExpandableEndNode) and | ||||
| 				(force_all or n.always_expand) | ||||
| 			): | ||||
| 			if ptr.parent is None: | ||||
| 				ptr = ptr.expand()[1] | ||||
| 				out = ptr | ||||
| 				ptr._set_parent(None, None) | ||||
| 			else: | ||||
| 				ptr.parent.set_side( | ||||
| 					ptr.parent_side, # type: ignore | ||||
| 					ptr.expand()[1] | ||||
| 				) | ||||
| 				ptr = ptr.parent.get_side( | ||||
| 					ptr.parent_side # type: ignore | ||||
| 				) | ||||
|  | ||||
| 			n.parent.set_side( | ||||
| 				n.parent_side, # type: ignore | ||||
| 				n.expand()[1] | ||||
| 			) | ||||
| 			it.ptr = n.parent.get_side( | ||||
| 				n.parent_side # type: ignore | ||||
| 			) | ||||
| 			macro_expansions += 1 | ||||
|  | ||||
|  | ||||
| 		# Tree walk logic | ||||
| 		if isinstance(ptr, EndNode): | ||||
| 			from_side, ptr = ptr.go_up() | ||||
| 		elif isinstance(ptr, Func): | ||||
| 			if from_side == Direction.UP: | ||||
| 				from_side, ptr = ptr.go_left() | ||||
| 			elif from_side == Direction.LEFT: | ||||
| 				from_side, ptr = ptr.go_up() | ||||
| 		elif isinstance(ptr, Call): | ||||
| 			if from_side == Direction.UP: | ||||
| 				from_side, ptr = ptr.go_left() | ||||
| 			elif from_side == Direction.LEFT: | ||||
| 				from_side, ptr = ptr.go_right() | ||||
| 			elif from_side == Direction.RIGHT: | ||||
| 				from_side, ptr = ptr.go_up() | ||||
| 		if ptr is node.parent: | ||||
| 			break | ||||
|  | ||||
| 	return macro_expansions, out | ||||
| @ -13,6 +13,7 @@ class StopReason(enum.Enum): | ||||
| 	LOOP_DETECTED	= ("class:warn", "Loop detected") | ||||
| 	MAX_EXCEEDED	= ("class:err", "Too many reductions") | ||||
| 	INTERRUPT		= ("class:warn", "User interrupt") | ||||
| 	SHOW_MACRO		= ("class:text", "Displaying macro content") | ||||
|  | ||||
| class MacroDef: | ||||
| 	@staticmethod | ||||
| @ -32,11 +33,6 @@ class MacroDef: | ||||
| 	def __str__(self): | ||||
| 		return f"{self.label} := {self.expr}" | ||||
|  | ||||
| 	def bind_variables(self, *, ban_macro_name = None): | ||||
| 		return self.expr.bind_variables( | ||||
| 			ban_macro_name = ban_macro_name | ||||
| 		) | ||||
|  | ||||
| 	def set_runner(self, runner): | ||||
| 		return self.expr.set_runner(runner) | ||||
|  | ||||
| @ -88,27 +84,30 @@ class Runner: | ||||
| 		# so that all digits appear to be changing. | ||||
| 		self.iter_update = 231 | ||||
|  | ||||
| 		self.history = [] | ||||
| 		self.history: list[lamb.node.Root] = [] | ||||
|  | ||||
| 	def prompt(self): | ||||
| 		return self.prompt_session.prompt( | ||||
| 			message = self.prompt_message | ||||
| 		) | ||||
|  | ||||
| 	def parse(self, line) -> tuple[lamb.node.Node | MacroDef | Command, dict]: | ||||
| 	def parse(self, line) -> tuple[lamb.node.Root | MacroDef | Command, dict]: | ||||
| 		e = self.parser.parse_line(line) | ||||
|  | ||||
| 		o = {} | ||||
| 		if isinstance(e, MacroDef): | ||||
| 			e.expr = lamb.node.Root(e.expr) | ||||
| 			e.set_runner(self) | ||||
| 			o = e.bind_variables(ban_macro_name = e.label) | ||||
| 			o = lamb.node.prepare(e.expr, ban_macro_name = e.label) | ||||
| 		elif isinstance(e, lamb.node.Node): | ||||
| 			e = lamb.node.Root(e) | ||||
| 			e.set_runner(self) | ||||
| 			o = e.bind_variables() | ||||
| 			o = lamb.node.prepare(e) | ||||
|  | ||||
| 		return e, o | ||||
|  | ||||
|  | ||||
| 	def reduce(self, node: lamb.node.Node, *, status = {}) -> None: | ||||
| 	def reduce(self, node: lamb.node.Root, *, status = {}) -> None: | ||||
|  | ||||
| 		warning_text = [] | ||||
|  | ||||
| @ -130,12 +129,9 @@ class Runner: | ||||
| 				("class:warn", "\n") | ||||
| 			] | ||||
|  | ||||
| 		only_macro = isinstance(node, lamb.node.ExpandableEndNode) | ||||
| 		only_macro = isinstance(node.left, lamb.node.Macro) | ||||
| 		if only_macro: | ||||
| 			warning_text += [ | ||||
| 				("class:warn", "All macros will be expanded"), | ||||
| 				("class:warn", "\n") | ||||
| 			] | ||||
| 			stop_reason = StopReason.SHOW_MACRO | ||||
| 		m, node = lamb.node.expand(node, force_all = only_macro) | ||||
| 		macro_expansions += m | ||||
|  | ||||
| @ -147,10 +143,16 @@ class Runner: | ||||
| 				("class:warn", " is a free variable\n"), | ||||
| 			] | ||||
|  | ||||
| 		printf(FormattedText(warning_text), style = lamb.utils.style) | ||||
| 		if len(warning_text) != 0: | ||||
| 			printf(FormattedText(warning_text), style = lamb.utils.style) | ||||
|  | ||||
|  | ||||
| 		while (self.reduction_limit is None) or (k < self.reduction_limit): | ||||
| 		while ( | ||||
| 				( | ||||
| 					(self.reduction_limit is None) or | ||||
| 					(k < self.reduction_limit) | ||||
| 				) and not only_macro | ||||
| 			): | ||||
|  | ||||
| 			# Show reduction count | ||||
| 			if (k >= self.iter_update) and (k % self.iter_update == 0): | ||||
| @ -177,22 +179,32 @@ class Runner: | ||||
| 			# Clear reduction counter if it was printed | ||||
| 			print(" " * round(14 + math.log10(k)), end = "\r") | ||||
|  | ||||
| 		out_text += [ | ||||
| 			("class:result_header", f"Runtime: "), | ||||
| 			("class:text", f"{time.time() - start_time:.03f} seconds"), | ||||
| 		if only_macro: | ||||
| 			out_text += [ | ||||
| 				("class:result_header", f"Displaying macro content") | ||||
| 			] | ||||
|  | ||||
| 			("class:result_header", f"\nExit reason: "), | ||||
| 			stop_reason.value, | ||||
| 		else: | ||||
| 			out_text += [ | ||||
| 				("class:result_header", f"Runtime: "), | ||||
| 				("class:text", f"{time.time() - start_time:.03f} seconds"), | ||||
|  | ||||
| 			("class:result_header", f"\nMacro expansions: "), | ||||
| 			("class:text", f"{macro_expansions:,}"), | ||||
| 				("class:result_header", f"\nExit reason: "), | ||||
| 				stop_reason.value, | ||||
|  | ||||
| 			("class:result_header", f"\nReductions: "), | ||||
| 			("class:text", f"{k:,}\t"), | ||||
| 			("class:muted", f"(Limit: {self.reduction_limit:,})") | ||||
| 		] | ||||
| 				("class:result_header", f"\nMacro expansions: "), | ||||
| 				("class:text", f"{macro_expansions:,}"), | ||||
|  | ||||
| 		if (stop_reason == StopReason.BETA_NORMAL or stop_reason == StopReason.LOOP_DETECTED): | ||||
| 				("class:result_header", f"\nReductions: "), | ||||
| 				("class:text", f"{k:,}\t"), | ||||
| 				("class:muted", f"(Limit: {self.reduction_limit:,})") | ||||
| 			] | ||||
|  | ||||
| 		if ( | ||||
| 				stop_reason == StopReason.BETA_NORMAL or | ||||
| 				stop_reason == StopReason.LOOP_DETECTED or | ||||
| 				only_macro | ||||
| 		): | ||||
| 			out_text += [ | ||||
| 				("class:result_header", "\n\n    => "), | ||||
| 				("class:text", str(node)), # type: ignore | ||||
|  | ||||
| @ -14,7 +14,7 @@ description = "A lambda calculus engine" | ||||
| # | ||||
| # Patch release: | ||||
| # Small, compatible fixes. | ||||
| version = "0.1.1" | ||||
| version = "0.1.2" | ||||
|  | ||||
| dependencies = [ | ||||
| 	"prompt-toolkit==3.0.31", | ||||
|  | ||||
		Reference in New Issue
	
	Block a user