camel-ai 0.2.71a1__py3-none-any.whl → 0.2.71a2__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.

Potentially problematic release.


This version of camel-ai might be problematic. Click here for more details.

@@ -22,7 +22,12 @@ logger = logging.getLogger(__name__)
22
22
 
23
23
 
24
24
  class HumanToolkit(BaseToolkit):
25
- r"""A class representing a toolkit for human interaction."""
25
+ r"""A class representing a toolkit for human interaction.
26
+
27
+ Note:
28
+ This toolkit should be called to send a tidy message to the user to
29
+ keep them informed.
30
+ """
26
31
 
27
32
  def ask_human_via_console(self, question: str) -> str:
28
33
  r"""Use this tool to ask a question to the user when you are stuck,
@@ -48,21 +53,21 @@ class HumanToolkit(BaseToolkit):
48
53
  return reply
49
54
 
50
55
  def send_message_to_user(self, message: str) -> None:
51
- r"""Use this tool to send a message to the user to keep them
52
- informed about your progress, decisions, or actions.
53
- This is a one-way communication channel from you to the user and does
54
- not require a response. You should use it to:
55
- - Announce what you are about to do
56
- (e.g., "I will now search for papers on GUI Agents.")
57
- - Report the result of an action
58
- (e.g., "I have found 15 relevant papers.")
59
- - State a decision
60
- (e.g., "I will now analyze the top 10 papers.")
61
- - Inform the user about your current state if you are performing a
62
- task.
56
+ r"""Use this tool to send a tidy message to the user in one short
57
+ sentence.
58
+
59
+ This one-way tool keeps the user informed about your progress,
60
+ decisions, or actions. It does not require a response.
61
+ You should use it to:
62
+ - Announce what you are about to do (e.g., "I will now search for
63
+ papers on GUI Agents.").
64
+ - Report the result of an action (e.g., "I have found 15 relevant
65
+ papers.").
66
+ - State a decision (e.g., "I will now analyze the top 10 papers.").
67
+ - Give a status update during a long-running task.
63
68
 
64
69
  Args:
65
- message (str): The message to send to the user.
70
+ message (str): The tidy and informative message for the user.
66
71
  """
67
72
  print(f"\nAgent Message:\n{message}")
68
73
  logger.info(f"\nAgent Message:\n{message}")
@@ -34,7 +34,7 @@ class JinaRerankerToolkit(BaseToolkit):
34
34
  def __init__(
35
35
  self,
36
36
  timeout: Optional[float] = None,
37
- model_name: Optional[str] = "jinaai/jina-reranker-m0",
37
+ model_name: str = "jinaai/jina-reranker-m0",
38
38
  device: Optional[str] = None,
39
39
  use_api: bool = True,
40
40
  ) -> None:
@@ -44,9 +44,8 @@ class JinaRerankerToolkit(BaseToolkit):
44
44
  timeout (Optional[float]): The timeout value for API requests
45
45
  in seconds. If None, no timeout is applied.
46
46
  (default: :obj:`None`)
47
- model_name (Optional[str]): The reranker model name. If None,
48
- will use the default model.
49
- (default: :obj:`None`)
47
+ model_name (str): The reranker model name.
48
+ (default: :obj:`"jinaai/jina-reranker-m0"`)
50
49
  device (Optional[str]): Device to load the model on. If None,
51
50
  will use CUDA if available, otherwise CPU.
52
51
  Only effective when use_api=False.
@@ -84,6 +84,7 @@ class TerminalToolkit(BaseToolkit):
84
84
  self._file_initialized = False
85
85
  self.cloned_env_path = None
86
86
  self.use_shell_mode = use_shell_mode
87
+ self._human_takeover_active = False
87
88
 
88
89
  self.python_executable = sys.executable
89
90
  self.is_macos = platform.system() == 'Darwin'
@@ -705,59 +706,35 @@ class TerminalToolkit(BaseToolkit):
705
706
  elif command.startswith('pip'):
706
707
  command = command.replace('pip', pip_path, 1)
707
708
 
708
- if self.is_macos:
709
- # Type safe version - macOS uses subprocess.run
710
- process = subprocess.run(
711
- command,
712
- shell=True,
713
- cwd=self.working_dir,
714
- capture_output=True,
715
- text=True,
716
- env=os.environ.copy(),
717
- )
718
-
719
- # Process the output
720
- output = process.stdout or ""
721
- if process.stderr:
722
- output += f"\nStderr Output:\n{process.stderr}"
723
-
724
- # Update session information and terminal
725
- self.shell_sessions[id]["output"] = output
726
- self._update_terminal_output(output + "\n")
727
-
728
- return output
729
-
730
- else:
731
- # Non-macOS systems use the Popen method
732
- proc = subprocess.Popen(
733
- command,
734
- shell=True,
735
- cwd=self.working_dir,
736
- stdout=subprocess.PIPE,
737
- stderr=subprocess.PIPE,
738
- stdin=subprocess.PIPE,
739
- text=True,
740
- bufsize=1,
741
- universal_newlines=True,
742
- env=os.environ.copy(),
743
- )
709
+ proc = subprocess.Popen(
710
+ command,
711
+ shell=True,
712
+ cwd=self.working_dir,
713
+ stdout=subprocess.PIPE,
714
+ stderr=subprocess.PIPE,
715
+ stdin=subprocess.PIPE,
716
+ text=True,
717
+ bufsize=1,
718
+ universal_newlines=True,
719
+ env=os.environ.copy(),
720
+ )
744
721
 
745
- # Store the process and mark it as running
746
- self.shell_sessions[id]["process"] = proc
747
- self.shell_sessions[id]["running"] = True
722
+ # Store the process and mark it as running
723
+ self.shell_sessions[id]["process"] = proc
724
+ self.shell_sessions[id]["running"] = True
748
725
 
749
- # Get output
750
- stdout, stderr = proc.communicate()
726
+ # Get output
727
+ stdout, stderr = proc.communicate()
751
728
 
752
- output = stdout or ""
753
- if stderr:
754
- output += f"\nStderr Output:\n{stderr}"
729
+ output = stdout or ""
730
+ if stderr:
731
+ output += f"\nStderr Output:\n{stderr}"
755
732
 
756
- # Update session information and terminal
757
- self.shell_sessions[id]["output"] = output
758
- self._update_terminal_output(output + "\n")
733
+ # Update session information and terminal
734
+ self.shell_sessions[id]["output"] = output
735
+ self._update_terminal_output(output + "\n")
759
736
 
760
- return output
737
+ return output
761
738
 
762
739
  except Exception as e:
763
740
  error_msg = f"Command execution error: {e!s}"
@@ -961,6 +938,169 @@ class TerminalToolkit(BaseToolkit):
961
938
  logger.error(f"Error killing process: {e}")
962
939
  return f"Error killing process: {e!s}"
963
940
 
941
+ def ask_user_for_help(self, id: str) -> str:
942
+ r"""Pauses agent execution to ask a human for help in the terminal.
943
+
944
+ This function should be called when an agent is stuck or needs
945
+ assistance with a task that requires manual intervention (e.g.,
946
+ solving a CAPTCHA or complex debugging). The human will take over the
947
+ specified terminal session to execute commands and then return control
948
+ to the agent.
949
+
950
+ Args:
951
+ id (str): Identifier of the shell session for the human to
952
+ interact with. If the session does not yet exist, it will be
953
+ created automatically.
954
+
955
+ Returns:
956
+ str: A status message indicating that the human has finished,
957
+ including the number of commands executed.
958
+ """
959
+ # Input validation
960
+ if not id or not isinstance(id, str):
961
+ return "Error: Invalid session ID provided"
962
+
963
+ # Prevent concurrent human takeovers
964
+ if (
965
+ hasattr(self, '_human_takeover_active')
966
+ and self._human_takeover_active
967
+ ):
968
+ return "Error: Human takeover already in progress"
969
+
970
+ try:
971
+ self._human_takeover_active = True
972
+
973
+ # Ensure the session exists so that the human can reuse it
974
+ if id not in self.shell_sessions:
975
+ self.shell_sessions[id] = {
976
+ "process": None,
977
+ "output": "",
978
+ "running": False,
979
+ }
980
+
981
+ command_count = 0
982
+ error_occurred = False
983
+
984
+ # Create clear banner message for user
985
+ takeover_banner = (
986
+ f"\n{'='*60}\n"
987
+ f"🤖 CAMEL Agent needs human help! Session: {id}\n"
988
+ f"📂 Working directory: {self.working_dir}\n"
989
+ f"{'='*60}\n"
990
+ f"💡 Type commands or '/exit' to return control to agent.\n"
991
+ f"{'='*60}\n"
992
+ )
993
+
994
+ # Print once to console for immediate visibility
995
+ print(takeover_banner, flush=True)
996
+ # Log for terminal output tracking
997
+ self._update_terminal_output(takeover_banner)
998
+
999
+ # Helper flag + event for coordination
1000
+ done_event = threading.Event()
1001
+
1002
+ def _human_loop() -> None:
1003
+ r"""Blocking loop that forwards human input to shell_exec."""
1004
+ nonlocal command_count, error_occurred
1005
+ try:
1006
+ while True:
1007
+ try:
1008
+ # Clear, descriptive prompt for user input
1009
+ user_cmd = input(f"🧑‍💻 [{id}]> ")
1010
+ if (
1011
+ user_cmd.strip()
1012
+ ): # Only count non-empty commands
1013
+ command_count += 1
1014
+ except EOFError:
1015
+ # e.g. Ctrl_D / stdin closed, treat as exit.
1016
+ break
1017
+ except (KeyboardInterrupt, Exception) as e:
1018
+ logger.warning(
1019
+ f"Input error during human takeover: {e}"
1020
+ )
1021
+ error_occurred = True
1022
+ break
1023
+
1024
+ if user_cmd.strip() in {"/exit", "exit", "quit"}:
1025
+ break
1026
+
1027
+ try:
1028
+ exec_result = self.shell_exec(id, user_cmd)
1029
+ # Show the result immediately to the user
1030
+ if exec_result.strip():
1031
+ print(exec_result)
1032
+ logger.info(
1033
+ f"Human command executed: {user_cmd[:50]}..."
1034
+ )
1035
+ # Auto-exit after successful command
1036
+ break
1037
+ except Exception as e:
1038
+ error_msg = f"Error executing command: {e}"
1039
+ logger.error(f"Error executing human command: {e}")
1040
+ print(error_msg) # Show error to user immediately
1041
+ self._update_terminal_output(f"{error_msg}\n")
1042
+ error_occurred = True
1043
+
1044
+ except Exception as e:
1045
+ logger.error(f"Unexpected error in human loop: {e}")
1046
+ error_occurred = True
1047
+ finally:
1048
+ # Notify completion clearly
1049
+ finish_msg = (
1050
+ f"\n{'='*60}\n"
1051
+ f"✅ Human assistance completed! "
1052
+ f"Commands: {command_count}\n"
1053
+ f"🤖 Returning control to CAMEL agent...\n"
1054
+ f"{'='*60}\n"
1055
+ )
1056
+ print(finish_msg, flush=True)
1057
+ self._update_terminal_output(finish_msg)
1058
+ done_event.set()
1059
+
1060
+ # Start interactive thread (non-daemon for proper cleanup)
1061
+ thread = threading.Thread(target=_human_loop, daemon=False)
1062
+ thread.start()
1063
+
1064
+ # Block until human signals completion with timeout
1065
+ if done_event.wait(timeout=600): # 10 minutes timeout
1066
+ thread.join(timeout=10) # Give thread time to cleanup
1067
+
1068
+ # Generate detailed status message
1069
+ status = "completed successfully"
1070
+ if error_occurred:
1071
+ status = "completed with some errors"
1072
+
1073
+ result_msg = (
1074
+ f"Human assistance {status} for session '{id}'. "
1075
+ f"Total commands executed: {command_count}. "
1076
+ f"Working directory: {self.working_dir}"
1077
+ )
1078
+ logger.info(result_msg)
1079
+ return result_msg
1080
+ else:
1081
+ timeout_msg = (
1082
+ f"Human takeover for session '{id}' timed out after 10 "
1083
+ "minutes"
1084
+ )
1085
+ logger.warning(timeout_msg)
1086
+ return timeout_msg
1087
+
1088
+ except Exception as e:
1089
+ error_msg = f"Error during human takeover for session '{id}': {e}"
1090
+ logger.error(error_msg)
1091
+ # Notify user of the error clearly
1092
+ error_banner = (
1093
+ f"\n{'='*60}\n"
1094
+ f"❌ Error in human takeover! Session: {id}\n"
1095
+ f"❗ {e}\n"
1096
+ f"{'='*60}\n"
1097
+ )
1098
+ print(error_banner, flush=True)
1099
+ return error_msg
1100
+ finally:
1101
+ # Always reset the flag
1102
+ self._human_takeover_active = False
1103
+
964
1104
  def __del__(self):
965
1105
  r"""Clean up resources when the object is being destroyed.
966
1106
  Terminates all running processes and closes any open file handles.
@@ -1042,4 +1182,5 @@ class TerminalToolkit(BaseToolkit):
1042
1182
  FunctionTool(self.shell_wait),
1043
1183
  FunctionTool(self.shell_write_to_process),
1044
1184
  FunctionTool(self.shell_kill_process),
1185
+ FunctionTool(self.ask_user_for_help),
1045
1186
  ]
@@ -26,7 +26,7 @@ from PIL import Image
26
26
  from camel.logger import get_logger
27
27
  from camel.toolkits.base import BaseToolkit
28
28
  from camel.toolkits.function_tool import FunctionTool
29
- from camel.utils import MCPServer, dependencies_required
29
+ from camel.utils import dependencies_required
30
30
 
31
31
  logger = get_logger(__name__)
32
32
 
@@ -57,7 +57,6 @@ def _capture_screenshot(video_file: str, timestamp: float) -> Image.Image:
57
57
  return Image.open(io.BytesIO(out))
58
58
 
59
59
 
60
- @MCPServer()
61
60
  class VideoDownloaderToolkit(BaseToolkit):
62
61
  r"""A class for downloading videos and optionally splitting them into
63
62
  chunks.
@@ -0,0 +1,148 @@
1
+ # ========= Copyright 2023-2024 @ CAMEL-AI.org. All Rights Reserved. =========
2
+ # Licensed under the Apache License, Version 2.0 (the "License");
3
+ # you may not use this file except in compliance with the License.
4
+ # You may obtain a copy of the License at
5
+ #
6
+ # http://www.apache.org/licenses/LICENSE-2.0
7
+ #
8
+ # Unless required by applicable law or agreed to in writing, software
9
+ # distributed under the License is distributed on an "AS IS" BASIS,
10
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11
+ # See the License for the specific language governing permissions and
12
+ # limitations under the License.
13
+ # ========= Copyright 2023-2024 @ CAMEL-AI.org. All Rights Reserved. =========
14
+ from typing import List, Optional
15
+
16
+ from pydantic import BaseModel, Field
17
+
18
+ from camel.agents import ChatAgent
19
+ from camel.messages import BaseMessage
20
+ from camel.models import BaseModelBackend, ModelFactory
21
+ from camel.types import ModelPlatformType, ModelType
22
+
23
+
24
+ class MessageSummary(BaseModel):
25
+ r"""Schema for structured message summaries.
26
+
27
+ Attributes:
28
+ summary (str): A brief, one-sentence summary of the conversation.
29
+ participants (List[str]): The roles of participants involved.
30
+ key_topics_and_entities (List[str]): Important topics, concepts, and
31
+ entities discussed.
32
+ decisions_and_outcomes (List[str]): Key decisions, conclusions, or
33
+ outcomes reached.
34
+ action_items (List[str]): A list of specific tasks or actions to be
35
+ taken, with assignees if mentioned.
36
+ progress_on_main_task (str): A summary of progress made on the
37
+ primary task.
38
+ """
39
+
40
+ summary: str = Field(
41
+ description="A brief, one-sentence summary of the conversation."
42
+ )
43
+ participants: List[str] = Field(
44
+ description="The roles of participants involved."
45
+ )
46
+ key_topics_and_entities: List[str] = Field(
47
+ description="Important topics, concepts, and entities discussed."
48
+ )
49
+ decisions_and_outcomes: List[str] = Field(
50
+ description="Key decisions, conclusions, or outcomes reached."
51
+ )
52
+ action_items: List[str] = Field(
53
+ description=(
54
+ "A list of specific tasks or actions to be taken, with assignees "
55
+ "if mentioned."
56
+ )
57
+ )
58
+ progress_on_main_task: str = Field(
59
+ description="A summary of progress made on the primary task."
60
+ )
61
+
62
+
63
+ class MessageSummarizer:
64
+ r"""Utility class for generating structured summaries of chat messages.
65
+
66
+ Args:
67
+ model_backend (Optional[BaseModelBackend], optional):
68
+ The model backend to use for summarization.
69
+ If not provided, a default model backend will be created.
70
+ """
71
+
72
+ def __init__(
73
+ self,
74
+ model_backend: Optional[BaseModelBackend] = None,
75
+ ):
76
+ if model_backend is None:
77
+ self.model_backend = ModelFactory.create(
78
+ model_platform=ModelPlatformType.DEFAULT,
79
+ model_type=ModelType.GPT_4O_MINI,
80
+ )
81
+ else:
82
+ self.model_backend = model_backend
83
+ self.agent = ChatAgent(
84
+ BaseMessage.make_assistant_message(
85
+ role_name="Message Summarizer",
86
+ content=(
87
+ "You are an expert conversation summarizer. Your task is "
88
+ "to analyze chat messages and create a structured summary "
89
+ "in JSON format. The summary should capture:\n"
90
+ "- summary: A brief, one-sentence summary of the "
91
+ "conversation.\n"
92
+ "- participants: The roles of participants involved.\n"
93
+ "- key_topics_and_entities: Important topics, concepts, "
94
+ "and entities discussed.\n"
95
+ "- decisions_and_outcomes: Key decisions, conclusions, or "
96
+ "outcomes reached.\n"
97
+ "- action_items: A list of specific tasks or actions to "
98
+ "be taken, with assignees if mentioned.\n"
99
+ "- progress_on_main_task: A summary of progress made on "
100
+ "the primary task.\n\n"
101
+ "Your response must be a JSON object that strictly "
102
+ "adheres to this structure. Be concise and accurate."
103
+ ),
104
+ ),
105
+ model=self.model_backend,
106
+ )
107
+
108
+ def summarize(self, messages: List[BaseMessage]) -> MessageSummary:
109
+ r"""Generate a structured summary of the provided messages.
110
+
111
+ Args:
112
+ messages (List[BaseMessage]): List of messages to summarize.
113
+
114
+ Returns:
115
+ MessageSummary: Structured summary of the conversation.
116
+
117
+ Raises:
118
+ ValueError: If the messages list is empty or if the model's
119
+ response cannot be parsed as valid JSON.
120
+ """
121
+ if not messages:
122
+ raise ValueError("Cannot summarize an empty list of messages.")
123
+
124
+ # Construct prompt from messages
125
+ message_text = "\n".join(
126
+ f"{msg.role_name}: {msg.content}" for msg in messages
127
+ )
128
+ prompt = (
129
+ "Please analyze the following chat messages and generate a "
130
+ "structured summary.\n\n"
131
+ f"MESSAGES:\n\"\"\"\n{message_text}\n\"\"\"\n\n"
132
+ "Your response must be a JSON object that strictly adheres to the "
133
+ "required format."
134
+ )
135
+
136
+ # Get structured summary from model with forced JSON response
137
+ response = self.agent.step(prompt, response_format=MessageSummary)
138
+
139
+ if response.msg is None or response.msg.parsed is None:
140
+ raise ValueError(
141
+ "Failed to get a structured summary from the model."
142
+ )
143
+
144
+ summary = response.msg.parsed
145
+ if not isinstance(summary, MessageSummary):
146
+ raise ValueError("The parsed response is not a MessageSummary.")
147
+
148
+ return summary
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: camel-ai
3
- Version: 0.2.71a1
3
+ Version: 0.2.71a2
4
4
  Summary: Communicative Agents for AI Society Study
5
5
  Project-URL: Homepage, https://www.camel-ai.org/
6
6
  Project-URL: Repository, https://github.com/camel-ai/camel
@@ -54,7 +54,6 @@ Requires-Dist: ffmpeg-python<0.3,>=0.2.0; extra == 'all'
54
54
  Requires-Dist: firecrawl-py<2,>=1.0.0; extra == 'all'
55
55
  Requires-Dist: fish-audio-sdk<2025,>=2024.12.5; extra == 'all'
56
56
  Requires-Dist: flask>=2.0; extra == 'all'
57
- Requires-Dist: fpdf>=1.7.2; extra == 'all'
58
57
  Requires-Dist: google-api-python-client==2.166.0; extra == 'all'
59
58
  Requires-Dist: google-auth-httplib2==0.2.0; extra == 'all'
60
59
  Requires-Dist: google-auth-oauthlib==1.2.1; extra == 'all'
@@ -99,6 +98,7 @@ Requires-Dist: pygithub<3,>=2.6.0; extra == 'all'
99
98
  Requires-Dist: pylatex>=1.4.2; extra == 'all'
100
99
  Requires-Dist: pymilvus<3,>=2.4.0; extra == 'all'
101
100
  Requires-Dist: pymupdf<2,>=1.22.5; extra == 'all'
101
+ Requires-Dist: pymupdf>=1.26.1; extra == 'all'
102
102
  Requires-Dist: pyobvector>=0.1.18; extra == 'all'
103
103
  Requires-Dist: pyowm<4,>=3.3.0; extra == 'all'
104
104
  Requires-Dist: pytelegrambotapi<5,>=4.18.0; extra == 'all'
@@ -206,7 +206,6 @@ Requires-Dist: chunkr-ai>=0.0.50; extra == 'document-tools'
206
206
  Requires-Dist: crawl4ai>=0.3.745; extra == 'document-tools'
207
207
  Requires-Dist: docx2txt<0.9,>=0.8; extra == 'document-tools'
208
208
  Requires-Dist: docx>=0.2.4; extra == 'document-tools'
209
- Requires-Dist: fpdf>=1.7.2; extra == 'document-tools'
210
209
  Requires-Dist: markitdown==0.1.1; extra == 'document-tools'
211
210
  Requires-Dist: numpy<=2.2,>=1.2; extra == 'document-tools'
212
211
  Requires-Dist: openapi-spec-validator<0.8,>=0.7.1; extra == 'document-tools'
@@ -215,6 +214,7 @@ Requires-Dist: pandasai<3,>=2.3.0; extra == 'document-tools'
215
214
  Requires-Dist: prance<24,>=23.6.21.0; extra == 'document-tools'
216
215
  Requires-Dist: pylatex>=1.4.2; extra == 'document-tools'
217
216
  Requires-Dist: pymupdf<2,>=1.22.5; extra == 'document-tools'
217
+ Requires-Dist: pymupdf>=1.26.1; extra == 'document-tools'
218
218
  Requires-Dist: python-pptx>=1.0.2; extra == 'document-tools'
219
219
  Requires-Dist: tabulate>=0.9.0; extra == 'document-tools'
220
220
  Requires-Dist: unstructured==0.16.20; extra == 'document-tools'
@@ -254,7 +254,6 @@ Requires-Dist: docx>=0.2.4; extra == 'owl'
254
254
  Requires-Dist: duckduckgo-search<7,>=6.3.5; extra == 'owl'
255
255
  Requires-Dist: e2b-code-interpreter<2,>=1.0.3; extra == 'owl'
256
256
  Requires-Dist: ffmpeg-python<0.3,>=0.2.0; extra == 'owl'
257
- Requires-Dist: fpdf>=1.7.2; extra == 'owl'
258
257
  Requires-Dist: html2text>=2024.2.26; extra == 'owl'
259
258
  Requires-Dist: imageio[pyav]<3,>=2.34.2; extra == 'owl'
260
259
  Requires-Dist: markitdown==0.1.1; extra == 'owl'
@@ -272,6 +271,7 @@ Requires-Dist: pyautogui<0.10,>=0.9.54; extra == 'owl'
272
271
  Requires-Dist: pydub<0.26,>=0.25.1; extra == 'owl'
273
272
  Requires-Dist: pylatex>=1.4.2; extra == 'owl'
274
273
  Requires-Dist: pymupdf<2,>=1.22.5; extra == 'owl'
274
+ Requires-Dist: pymupdf>=1.26.1; extra == 'owl'
275
275
  Requires-Dist: pytesseract>=0.3.13; extra == 'owl'
276
276
  Requires-Dist: python-dotenv<2,>=1.0.0; extra == 'owl'
277
277
  Requires-Dist: python-pptx>=1.0.2; extra == 'owl'