tuhulab commited on
Commit
2a26ed2
·
1 Parent(s): 0778a93

feat: add logging and pathlib examples to test audio processor notebook

Browse files
LEARNING_GUIDE.md CHANGED
@@ -76,6 +76,383 @@ graph TD
76
 
77
  ---
78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  ## Python Function Basics
80
 
81
  Functions are the primary way to group code into reusable blocks. Let's break down a function from our codebase: `utils/audio_processor.py`.
@@ -1221,7 +1598,7 @@ The app uses Python's logging:
1221
  ```python
1222
  logging.basicConfig(
1223
  level=logging.INFO, # Change to DEBUG for more detail
1224
- format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
1225
  )
1226
  ```
1227
 
 
76
 
77
  ---
78
 
79
+ ## Python Logging Module
80
+
81
+ ### What is Logging?
82
+
83
+ Logging is Python's built-in system for tracking events, debugging, and monitoring your application. It's much better than using `print()` statements for debugging.
84
+
85
+ ### Basic Setup
86
+
87
+ ```python
88
+ import logging
89
+
90
+ # Create a logger instance specific to this module
91
+ logger = logging.getLogger(__name__)
92
+
93
+ # Configure logging to display messages
94
+ logging.basicConfig(
95
+ level=logging.INFO, # Show INFO and above (INFO, WARNING, ERROR, CRITICAL)
96
+ format='%(levelname)s - %(name)s - %(message)s'
97
+ )
98
+
99
+ # Now you can log messages
100
+ logger.info("Audio processor functions module loaded.")
101
+ ```
102
+
103
+ ### Why Use `__name__` with Logger?
104
+
105
+ **Benefits of `getLogger(__name__)`:**
106
+
107
+ 1. **Hierarchical organization**: If your code is imported as a module (like `utils.audio_processor`), the logger name will be `"utils.audio_processor"` instead of `"__main__"`. This creates a logger hierarchy that helps organize logs from different parts of your app.
108
+
109
+ 2. **Filtering by module**: You can configure different log levels for different parts of your application:
110
+ ```python
111
+ logging.getLogger("agents").setLevel(logging.DEBUG) # Verbose for agents
112
+ logging.getLogger("utils").setLevel(logging.WARNING) # Quiet for utils
113
+ ```
114
+
115
+ 3. **Identifies source**: In log output, you can see exactly which module generated each message, making debugging much easier.
116
+
117
+ 4. **Best practice**: Prevents logger name conflicts and follows Python conventions.
118
+
119
+ ### Log Levels
120
+
121
+ From least to most severe:
122
+
123
+ | Level | When to Use | Example |
124
+ |-------|-------------|---------|
125
+ | `DEBUG` | Detailed diagnostic information | `logger.debug(f"Variable x = {x}")` |
126
+ | `INFO` | General informational messages | `logger.info("Processing started")` |
127
+ | `WARNING` | Something unexpected, but not an error | `logger.warning("Cache miss, fetching from API")` |
128
+ | `ERROR` | An error occurred, but app can continue | `logger.error(f"Failed to load file: {e}")` |
129
+ | `CRITICAL` | Serious error, app may crash | `logger.critical("Database connection lost!")` |
130
+
131
+ ### Why Logging Doesn't Show by Default
132
+
133
+ **The problem:** By default, loggers only show messages at WARNING level and above. Your `logger.info()` calls are ignored!
134
+
135
+ **The solution:** Configure logging with `basicConfig()` to set the minimum level:
136
+
137
+ ```python
138
+ logging.basicConfig(level=logging.INFO) # Now INFO messages will appear
139
+ ```
140
+
141
+ ### Format String Explained
142
+
143
+ ```python
144
+ format='%(levelname)s - %(name)s - %(message)s'
145
+ ```
146
+
147
+ This creates output like:
148
+ ```
149
+ INFO - __main__ - Audio processor functions module loaded.
150
+ ```
151
+
152
+ - `%(levelname)s` → Log level (INFO, ERROR, etc.)
153
+ - `%(name)s` → Logger name (from `__name__`)
154
+ - `%(message)s` → Your actual message
155
+
156
+ **Note:** You can add timestamps with `%(asctime)s` if you need them, but for simple learning it's cleaner without.
157
+
158
+ ### Practical Example
159
+
160
+ ```python
161
+ import logging
162
+
163
+ logger = logging.getLogger(__name__)
164
+
165
+ def process_audio(file_path):
166
+ logger.debug(f"Starting audio processing for: {file_path}") # Only in DEBUG mode
167
+
168
+ try:
169
+ # Process the file
170
+ logger.info(f"Successfully processed: {file_path}") # Normal operation
171
+ return True
172
+ except FileNotFoundError:
173
+ logger.error(f"File not found: {file_path}") # Error, but continue
174
+ return False
175
+ except Exception as e:
176
+ logger.critical(f"Critical error processing {file_path}: {e}") # Serious problem
177
+ raise
178
+ ```
179
+
180
+ ### Why Use Logging Instead of Print?
181
+
182
+ | Feature | `print()` | `logging` |
183
+ |---------|-----------|-----------|
184
+ | **Control output** | ❌ Always prints | ✅ Can turn on/off by level |
185
+ | **Timestamps** | ❌ Manual | ✅ Automatic |
186
+ | **File output** | ❌ Manual redirection | ✅ Built-in handlers |
187
+ | **Severity levels** | ❌ No distinction | ✅ DEBUG, INFO, WARNING, etc. |
188
+ | **Production-ready** | ❌ Need to remove/comment | ✅ Just change log level |
189
+ | **Module identification** | ❌ Manual | ✅ Automatic with `__name__` |
190
+
191
+ ### In Your Science Storyteller Project
192
+
193
+ You'll use logging to track:
194
+ - Which research papers were retrieved
195
+ - API call successes/failures
196
+ - Processing steps (search → summarize → TTS)
197
+ - Errors during workflow
198
+ - Performance timing
199
+
200
+ **Example from your project:**
201
+ ```python
202
+ logger.info(f"Searching for papers on topic: {topic}")
203
+ logger.warning("No papers found, trying fallback query")
204
+ logger.error(f"API call failed: {e}")
205
+ ```
206
+
207
+ ---
208
+
209
+ ## Working with File Paths: `pathlib.Path`
210
+
211
+ ### What is `pathlib`?
212
+
213
+ `pathlib` is Python's modern, object-oriented way to work with file system paths. It was introduced in **Python 3.4** (2014) and is now the recommended approach for handling files and directories.
214
+
215
+ ### Why Use `Path` Instead of Strings?
216
+
217
+ **Old way (strings and `os.path`):**
218
+ ```python
219
+ import os
220
+
221
+ path = "/home/user/audio.mp3"
222
+ if os.path.exists(path):
223
+ dirname = os.path.dirname(path)
224
+ basename = os.path.basename(path)
225
+ new_path = os.path.join(dirname, "new_audio.mp3")
226
+ ```
227
+
228
+ **New way (`pathlib.Path`):**
229
+ ```python
230
+ from pathlib import Path
231
+
232
+ path = Path("/home/user/audio.mp3")
233
+ if path.exists():
234
+ dirname = path.parent
235
+ basename = path.name
236
+ new_path = path.parent / "new_audio.mp3" # Use / operator!
237
+ ```
238
+
239
+ **Benefits:**
240
+ - ✅ More readable and intuitive
241
+ - ✅ Works across Windows/Mac/Linux automatically
242
+ - ✅ Chainable methods
243
+ - ✅ Less error-prone than string concatenation
244
+ - ✅ Object-oriented design
245
+
246
+ ### Creating Path Objects
247
+
248
+ ```python
249
+ from pathlib import Path
250
+
251
+ # From a string
252
+ p = Path("/home/user/app/assets/audio/test.mp3")
253
+
254
+ # From current directory
255
+ p = Path.cwd() # Current working directory. It does not need input path.
256
+
257
+ # From home directory
258
+ p = Path.home() # User's home directory (~)
259
+
260
+ # Relative paths
261
+ p = Path("./assets/audio")
262
+ ```
263
+
264
+ ### Path Properties and Methods
265
+
266
+ ```python
267
+ from pathlib import Path
268
+
269
+ p = Path("/home/user/app/assets/audio/podcast_123.mp3")
270
+
271
+ # Check existence and type
272
+ p.exists() # True/False - does it exist?
273
+ p.is_file() # True/False - is it a file?
274
+ p.is_dir() # True/False - is it a directory?
275
+
276
+ # Get path components
277
+ p.name # 'podcast_123.mp3' - filename with extension
278
+ p.stem # 'podcast_123' - filename without extension
279
+ p.suffix # '.mp3' - file extension
280
+ p.parent # Path('/home/user/app/assets/audio') - parent directory
281
+ p.parts # ('/', 'home', 'user', 'app', 'assets', 'audio', 'podcast_123.mp3')
282
+
283
+ # Path conversion
284
+ str(p) # Convert Path to string
285
+ p.absolute() # Get absolute path
286
+ p.resolve() # Resolve symlinks and make absolute
287
+ ```
288
+
289
+ ### Common Operations
290
+
291
+ **1. Check if file exists:**
292
+ ```python
293
+ path = Path("myfile.txt")
294
+ if path.exists():
295
+ print("File found!")
296
+ ```
297
+
298
+ **2. Create directories:**
299
+ ```python
300
+ audio_dir = Path("./assets/audio")
301
+ audio_dir.mkdir(parents=True, exist_ok=True)
302
+ # parents=True: creates parent directories if needed
303
+ # exist_ok=True: doesn't raise error if already exists
304
+ ```
305
+
306
+ **3. Join paths (the smart way):**
307
+ ```python
308
+ base = Path("./assets")
309
+ audio_file = base / "audio" / "test.mp3" # Use / operator!
310
+ # Result: Path('./assets/audio/test.mp3')
311
+
312
+ # Works with strings too!
313
+ file_path = base / "audio" / f"podcast_{123}.mp3"
314
+ ```
315
+
316
+ **4. Find files (glob patterns):**
317
+ ```python
318
+ audio_dir = Path("./assets/audio")
319
+
320
+ # All MP3 files in directory
321
+ mp3_files = list(audio_dir.glob("*.mp3"))
322
+
323
+ # All files recursively
324
+ all_files = list(audio_dir.glob("**/*"))
325
+
326
+ # Specific pattern
327
+ podcasts = list(audio_dir.glob("podcast_*.mp3"))
328
+ ```
329
+
330
+ **5. Read and write files:**
331
+ ```python
332
+ path = Path("data.txt")
333
+
334
+ # Write text
335
+ path.write_text("Hello, world!")
336
+
337
+ # Read text
338
+ content = path.read_text()
339
+
340
+ # Write bytes (for binary files)
341
+ path.write_bytes(b'\x89PNG...')
342
+
343
+ # Read bytes
344
+ data = path.read_bytes()
345
+ ```
346
+
347
+ **6. Get file metadata:**
348
+ ```python
349
+ path = Path("myfile.txt")
350
+
351
+ stats = path.stat()
352
+ size_bytes = stats.st_size
353
+ modified_time = stats.st_mtime
354
+ ```
355
+
356
+ ### Real Example from Your Project
357
+
358
+ From `utils/audio_processor.py`:
359
+
360
+ ```python
361
+ def process_audio_file(audio_path: str) -> Optional[str]:
362
+ """Validate an audio file using Path."""
363
+
364
+ # Convert string to Path object
365
+ path = Path(audio_path)
366
+
367
+ # Check if file exists
368
+ if not path.exists():
369
+ logger.error(f"Audio file not found: {audio_path}")
370
+ return None
371
+
372
+ # Check file extension
373
+ if not path.suffix.lower() in ['.mp3', '.wav', '.ogg']:
374
+ logger.error(f"Invalid audio format: {path.suffix}")
375
+ return None
376
+
377
+ # Convert back to string for return
378
+ return str(path)
379
+ ```
380
+
381
+ **Why this is better than strings:**
382
+ - `path.exists()` is clearer than `os.path.exists(audio_path)`
383
+ - `path.suffix` is simpler than manually parsing the extension
384
+ - Cross-platform compatible (Windows uses `\`, Unix uses `/`)
385
+ - Type-safe with IDE autocomplete
386
+
387
+ ### Advanced Example: Cleanup Old Files
388
+
389
+ ```python
390
+ from pathlib import Path
391
+
392
+ def cleanup_old_files(directory: str, max_files: int = 10):
393
+ """Remove oldest audio files, keeping only max_files."""
394
+
395
+ dir_path = Path(directory)
396
+
397
+ if not dir_path.exists():
398
+ return
399
+
400
+ # Get all MP3 files sorted by modification time
401
+ audio_files = sorted(
402
+ dir_path.glob('*.mp3'), # Find all MP3s
403
+ key=lambda p: p.stat().st_mtime, # Sort by modified time
404
+ reverse=True # Newest first
405
+ )
406
+
407
+ # Remove oldest files beyond max_files
408
+ for old_file in audio_files[max_files:]:
409
+ old_file.unlink() # Delete the file
410
+ logger.info(f"Removed old file: {old_file}")
411
+ ```
412
+
413
+ ### Path Version History
414
+
415
+ - **Python 3.4** (2014): `pathlib` introduced
416
+ - **Python 3.5** (2015): Bug fixes and improvements
417
+ - **Python 3.6+** (2016+): Standard library functions accept `Path` objects
418
+
419
+ **Backward compatibility:** If you need to support Python 2.7 or 3.3, use `pathlib2` package. But for modern projects (like yours), just use built-in `pathlib`.
420
+
421
+ ### Quick Reference Table
422
+
423
+ | Task | Old Way (`os.path`) | New Way (`pathlib.Path`) |
424
+ |------|---------------------|--------------------------|
425
+ | Check exists | `os.path.exists(path)` | `Path(path).exists()` |
426
+ | Get filename | `os.path.basename(path)` | `Path(path).name` |
427
+ | Get directory | `os.path.dirname(path)` | `Path(path).parent` |
428
+ | Join paths | `os.path.join(a, b)` | `Path(a) / b` |
429
+ | Get extension | Manual string split | `Path(path).suffix` |
430
+ | Create directory | `os.makedirs(path)` | `Path(path).mkdir(parents=True)` |
431
+ | List files | `os.listdir(path)` | `Path(path).iterdir()` |
432
+ | Read file | `open(path).read()` | `Path(path).read_text()` |
433
+
434
+ ### When to Convert Between Path and String
435
+
436
+ **Rule of thumb:**
437
+ - Use `Path` objects internally for all file operations
438
+ - Convert to `str()` only when:
439
+ - Passing to APIs that don't accept Path
440
+ - Displaying to user
441
+ - Storing in JSON or database
442
+
443
+ ```python
444
+ # Internal: use Path
445
+ path = Path("./assets/audio") / "file.mp3"
446
+
447
+ # External API: convert to string
448
+ audio_url = upload_to_api(str(path))
449
+
450
+ # Display to user: convert to string
451
+ print(f"Audio saved to: {path}") # Prints nicely automatically
452
+ ```
453
+
454
+ ---
455
+
456
  ## Python Function Basics
457
 
458
  Functions are the primary way to group code into reusable blocks. Let's break down a function from our codebase: `utils/audio_processor.py`.
 
1598
  ```python
1599
  logging.basicConfig(
1600
  level=logging.INFO, # Change to DEBUG for more detail
1601
+ format='%(levelname)s - %(name)s - %(message)s'
1602
  )
1603
  ```
1604
 
tests/retrieve.ipynb CHANGED
@@ -20,7 +20,7 @@
20
  },
21
  {
22
  "cell_type": "code",
23
- "execution_count": 3,
24
  "id": "3db54ea3",
25
  "metadata": {},
26
  "outputs": [
@@ -37,7 +37,7 @@
37
  }
38
  ],
39
  "source": [
40
- "ArxivTool.connect(self)"
41
  ]
42
  }
43
  ],
 
20
  },
21
  {
22
  "cell_type": "code",
23
+ "execution_count": null,
24
  "id": "3db54ea3",
25
  "metadata": {},
26
  "outputs": [
 
37
  }
38
  ],
39
  "source": [
40
+ "ArxivTool.connect()"
41
  ]
42
  }
43
  ],
tests/test_audio_processor.ipynb ADDED
@@ -0,0 +1,183 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "id": "b561f992",
6
+ "metadata": {},
7
+ "source": [
8
+ "# Test functions "
9
+ ]
10
+ },
11
+ {
12
+ "cell_type": "code",
13
+ "execution_count": null,
14
+ "id": "4060c315",
15
+ "metadata": {},
16
+ "outputs": [],
17
+ "source": [
18
+ "import os\n",
19
+ "from pathlib import Path\n",
20
+ "from typing import Optional"
21
+ ]
22
+ },
23
+ {
24
+ "cell_type": "code",
25
+ "execution_count": null,
26
+ "id": "f652ec87",
27
+ "metadata": {},
28
+ "outputs": [],
29
+ "source": [
30
+ "import logging\n",
31
+ "\n",
32
+ "# A logger instance specific to the module\n",
33
+ "logger = logging.getLogger(__name__)\n",
34
+ "\n",
35
+ "# Only configure if not already configured (prevents duplicate handlers)\n",
36
+ "if not logging.getLogger().hasHandlers():\n",
37
+ " logging.basicConfig(\n",
38
+ " level=logging.INFO, # Show INFO and above (INFO, WARNING, ERROR, CRITICAL)\n",
39
+ " format='%(asctime)s - %(levelname)s - %(message)s',\n",
40
+ " datefmt='%H:%M:%S' # Only show hours:minutes:seconds\n",
41
+ " )\n",
42
+ "\n",
43
+ "logger.info(\"Audio processor functions module loaded.\")\n"
44
+ ]
45
+ },
46
+ {
47
+ "cell_type": "markdown",
48
+ "id": "50c921a9",
49
+ "metadata": {},
50
+ "source": [
51
+ "## Download a sample audio file for testing"
52
+ ]
53
+ },
54
+ {
55
+ "cell_type": "code",
56
+ "execution_count": null,
57
+ "id": "4e97b64a",
58
+ "metadata": {},
59
+ "outputs": [],
60
+ "source": [
61
+ "import requests\n",
62
+ "\n",
63
+ "test_audio_path = \"../assets/audio/test_sample.mp3\"\n",
64
+ "\n",
65
+ "if Path(test_audio_path).exists():\n",
66
+ " logger.warning(f\"File already exists at {test_audio_path}, skipping download.\")\n",
67
+ " pass\n",
68
+ "else:\n",
69
+ " logger.info(f\"Downloading sample audio file to {test_audio_path}...\")\n",
70
+ " url = \"https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3\"\n",
71
+ " response = requests.get(url)\n",
72
+ " with open(test_audio_path, 'wb') as f:\n",
73
+ " f.write(response.content)\n",
74
+ "\n"
75
+ ]
76
+ },
77
+ {
78
+ "cell_type": "markdown",
79
+ "id": "660c57af",
80
+ "metadata": {},
81
+ "source": [
82
+ "## Process audio file"
83
+ ]
84
+ },
85
+ {
86
+ "cell_type": "markdown",
87
+ "id": "4fdd70ec",
88
+ "metadata": {},
89
+ "source": [
90
+ "## pathlib and Path()"
91
+ ]
92
+ },
93
+ {
94
+ "cell_type": "code",
95
+ "execution_count": null,
96
+ "id": "54d267ae",
97
+ "metadata": {},
98
+ "outputs": [],
99
+ "source": [
100
+ "# Test if the audio file exists\n",
101
+ "test_audio_path = \"../assets/audio/test_sample.mp3\"\n",
102
+ "\n",
103
+ "if Path(test_audio_path).exists():\n",
104
+ " logger.info(f\"Test audio file exists at {test_audio_path}.\")"
105
+ ]
106
+ },
107
+ {
108
+ "cell_type": "code",
109
+ "execution_count": null,
110
+ "id": "0b42d3f2",
111
+ "metadata": {},
112
+ "outputs": [
113
+ {
114
+ "ename": "",
115
+ "evalue": "",
116
+ "output_type": "error",
117
+ "traceback": [
118
+ "\u001b[1;31mThe Kernel crashed while executing code in the current cell or a previous cell. \n",
119
+ "\u001b[1;31mPlease review the code in the cell(s) to identify a possible cause of the failure. \n",
120
+ "\u001b[1;31mClick <a href='https://aka.ms/vscodeJupyterKernelCrash'>here</a> for more info. \n",
121
+ "\u001b[1;31mView Jupyter <a href='command:jupyter.viewOutput'>log</a> for further details."
122
+ ]
123
+ }
124
+ ],
125
+ "source": [
126
+ "p = Path(test_audio_path)\n",
127
+ "\n",
128
+ "if p.exists():\n",
129
+ " logger.info(f\"Audio file exists at: {p}\")\n",
130
+ " \n",
131
+ " # Path exists?\n",
132
+ " logger.info(f\"Path exists? {p.exists()}\")\n",
133
+ "else:\n",
134
+ " logger.error(f\"Audio file not found at: {test_audio_path}\")"
135
+ ]
136
+ },
137
+ {
138
+ "cell_type": "code",
139
+ "execution_count": null,
140
+ "id": "ccc7fa24",
141
+ "metadata": {},
142
+ "outputs": [],
143
+ "source": [
144
+ "print(Path.cwd())\n",
145
+ "print(Path.home())\n"
146
+ ]
147
+ },
148
+ {
149
+ "cell_type": "code",
150
+ "execution_count": null,
151
+ "id": "9452f2f7",
152
+ "metadata": {},
153
+ "outputs": [],
154
+ "source": [
155
+ "audio_path = \"../assets/audio/test_sample.mp3\"\n",
156
+ "\n",
157
+ "path = Path(audio_path)\n",
158
+ "print(path)"
159
+ ]
160
+ }
161
+ ],
162
+ "metadata": {
163
+ "kernelspec": {
164
+ "display_name": "Python 3",
165
+ "language": "python",
166
+ "name": "python3"
167
+ },
168
+ "language_info": {
169
+ "codemirror_mode": {
170
+ "name": "ipython",
171
+ "version": 3
172
+ },
173
+ "file_extension": ".py",
174
+ "mimetype": "text/x-python",
175
+ "name": "python",
176
+ "nbconvert_exporter": "python",
177
+ "pygments_lexer": "ipython3",
178
+ "version": "3.10.19"
179
+ }
180
+ },
181
+ "nbformat": 4,
182
+ "nbformat_minor": 5
183
+ }