mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-08-25 00:52:25 +02:00
fix cassette hashing
This commit is contained in:
@@ -24,14 +24,16 @@ function decompressData(compressedString: string): NockCassetteData {
|
||||
return msgpack.decode(decompressed) as NockCassetteData;
|
||||
}
|
||||
|
||||
// deno-lint-ignore no-unused-vars
|
||||
class HashedCassette {
|
||||
hash: string;
|
||||
private recording = true;
|
||||
|
||||
constructor(
|
||||
private readonly cassettePath: string,
|
||||
private readonly hash: string
|
||||
) {}
|
||||
hash: string
|
||||
) {
|
||||
this.hash = hash;
|
||||
}
|
||||
|
||||
async enter() {
|
||||
try {
|
||||
|
||||
@@ -63,7 +63,7 @@ class HashedCassette:
|
||||
content = f.read()
|
||||
try:
|
||||
cassette_data = serializer.deserialize(content)
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
os.remove(self.cassette_path)
|
||||
else:
|
||||
existing_hash = cassette_data.get("cassette_hash")
|
||||
@@ -96,7 +96,7 @@ class HashedCassette:
|
||||
content = f.read()
|
||||
try:
|
||||
cassette_data = serializer.deserialize(content)
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
return result
|
||||
# Update the cassette data with the expected hash.
|
||||
if cassette_data.get("cassette_hash") != self.hash_value:
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
hook_state = {
|
||||
"document_filename": "__UNKNOWN__",
|
||||
"document_content": "__UNKNOWN__",
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ from markdown_exec.hooks import SessionHistoryEntry
|
||||
|
||||
from _scripts.generate_api_reference_links import update_markdown_with_imports
|
||||
from _scripts.notebook_convert import convert_notebook
|
||||
from _scripts.setup_vcr import load_postamble, load_preamble, _hash_string
|
||||
from _scripts.setup_vcr import get_hash_for_session, load_postamble, load_preamble, _hash_string
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logging.basicConfig()
|
||||
@@ -157,7 +157,12 @@ def handle_vcr_setup(
|
||||
try:
|
||||
if hook_state['document_filename'] == '__UNKNOWN__':
|
||||
raise SuperFencesException(
|
||||
f"error while processing {language} block: document filename is unknown"
|
||||
f"error while processing {language} block: document filename hasn't been set yet"
|
||||
)
|
||||
|
||||
if hook_state['document_content'] == '__UNKNOWN__':
|
||||
raise SuperFencesException(
|
||||
f"error while processing {language} block: document content hasn't been set yet"
|
||||
)
|
||||
|
||||
if session is None or session == "" and id is None or id == "":
|
||||
@@ -181,8 +186,9 @@ def handle_vcr_setup(
|
||||
|
||||
# Add context manager at start with explicit __enter__ and __exit__ calls
|
||||
|
||||
hash_ = get_hash_for_session(language, session, hook_state['document_content'])
|
||||
wrapped_lines = [
|
||||
load_preamble(language, code, cassette_name),
|
||||
load_preamble(language, hash_, cassette_name),
|
||||
code,
|
||||
]
|
||||
|
||||
@@ -220,24 +226,17 @@ def handle_vcr_teardown(
|
||||
session: str,
|
||||
history: list[SessionHistoryEntry],
|
||||
):
|
||||
last_inputs = dict(history[-1].inputs)
|
||||
code = load_postamble(language)
|
||||
md = last_inputs["md"]
|
||||
html = False
|
||||
update_toc = False
|
||||
|
||||
path = last_inputs.get("extra", {}).get("path", None)
|
||||
|
||||
if path is None:
|
||||
logger.warning(f"no document filename found while tearing down {session}!")
|
||||
else:
|
||||
logger.info(f"tearing down {language} {session} on {path}")
|
||||
logger.info(f"tearing down {language} {session} on {hook_state['document_filename']}")
|
||||
|
||||
kwargs = dict(
|
||||
code=code,
|
||||
session=session,
|
||||
id=f"{id}_vcr_end",
|
||||
md=md,
|
||||
md=None, # md is unused by the formatter, but it's a required argument
|
||||
html=html,
|
||||
update_toc=update_toc,
|
||||
extra={},
|
||||
@@ -344,4 +343,5 @@ def on_post_build(config):
|
||||
def on_pre_page(page: Page, **kwargs: Dict[str, Any]):
|
||||
logger.info(f"on_pre_page: {page.file.src_path}")
|
||||
hook_state['document_filename'] = page.file.src_path
|
||||
hook_state['document_content'] = page.file.content_string
|
||||
return page
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
# A list of patterns that, if found in a code block, will cause us to leave that block unchanged.
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from textwrap import dedent
|
||||
import re
|
||||
from textwrap import dedent, indent
|
||||
|
||||
from mistune import BlockParser, BlockState, Markdown, create_markdown
|
||||
from mistune.renderers.markdown import MarkdownRenderer
|
||||
|
||||
preambles = {
|
||||
"python": "vcr_setup_preamble.py",
|
||||
@@ -50,21 +55,19 @@ preamble_cleanups = {
|
||||
}
|
||||
|
||||
|
||||
def load_preamble(language: str, code: str, cassette_name: str) -> str:
|
||||
def load_preamble(language: str, hash_: str, cassette_name: str) -> str:
|
||||
"""Load the source code for the preamble for a given language."""
|
||||
_assets_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "assets")
|
||||
|
||||
preamble_path = os.path.join(_assets_dir, preambles[language])
|
||||
with open(preamble_path, "r") as f:
|
||||
lines = f.readlines()
|
||||
hash_ = _hash_string(code)
|
||||
lines.append(preamble_inits[language](cassette_name, hash_))
|
||||
return "\n".join(lines).strip()
|
||||
|
||||
|
||||
def load_postamble(language: str) -> str:
|
||||
"""Load the source code for the postamble for a given language."""
|
||||
|
||||
return preamble_cleanups[language]()
|
||||
|
||||
|
||||
@@ -75,3 +78,63 @@ def _hash_string(input_string: str) -> str:
|
||||
sha256_hash = hashlib.sha256(encoded_string)
|
||||
# Get the hexadecimal digest of the hash
|
||||
return sha256_hash.hexdigest()
|
||||
|
||||
|
||||
def extract_code_blocks_for_session(language: str, session: str, content: str) -> str:
|
||||
code_blocks_for_session = []
|
||||
|
||||
TAB_REGEX = r"^===!? \"(?P<title>[^\"]+)\"\n(?P<content>(?:(?P<indent> )+[^\n]*\n)+)"
|
||||
def parse_tabs(block: BlockParser, m: re.Match, state: BlockState) -> str:
|
||||
state.append_token(
|
||||
{
|
||||
"raw": m.group(0),
|
||||
"type": "block_tab",
|
||||
"attrs": {
|
||||
"title": m.group("title"),
|
||||
"level": len(m.group("indent")) // 4,
|
||||
"content": dedent(m.group("content")).strip(),
|
||||
},
|
||||
}
|
||||
)
|
||||
return m.end()
|
||||
|
||||
def render_tabs(self, token: dict, state: BlockState):
|
||||
recursive_transformer = create_markdown(renderer=DocumentRenderer())
|
||||
recursive_transformer.block.register("block_tab", TAB_REGEX, parse_tabs, before='list')
|
||||
recursive_transformer.renderer.register("block_tab", render_tabs)
|
||||
return (
|
||||
f'=== "{token["attrs"]["title"]}"\n'
|
||||
f'{indent(recursive_transformer(token["attrs"]["content"]), " " * token["attrs"]["level"])}\n'
|
||||
)
|
||||
|
||||
class DocumentRenderer(MarkdownRenderer):
|
||||
def block_code(self, token: dict, state: BlockState):
|
||||
if token["style"] == "fenced":
|
||||
if token["attrs"]["info"]:
|
||||
attributes = {}
|
||||
block_language = token["attrs"]["info"].split()[0]
|
||||
for match in re.finditer(r'(?P<key>\w+)=(?:(?P<value>(?:[\w]+))|"(?P<value_quoted>(?:[^"\s]+))")', token["attrs"]["info"]):
|
||||
attributes[match.group("key")] = match.group("value") or match.group("value_quoted")
|
||||
if block_language == language and "session" in attributes and attributes["session"] == session:
|
||||
code_blocks_for_session.append(token["raw"].rstrip())
|
||||
return super().block_code(token, state)
|
||||
|
||||
transformer: Markdown = create_markdown(renderer=DocumentRenderer())
|
||||
transformer.block.register("block_tab", TAB_REGEX, parse_tabs, before='list')
|
||||
transformer.renderer.register("block_tab", render_tabs)
|
||||
|
||||
# Parses the page content, which causes the code blocks to be added to the code_blocks_for_session list.
|
||||
# There's probably some way to do this by using the renderer as a filter, but I would've had to NO-OP
|
||||
# all of the default behavior, and this was easier.
|
||||
transformer(content)
|
||||
|
||||
return code_blocks_for_session
|
||||
|
||||
|
||||
def get_hash_for_session(language: str, session: str, content: str) -> str:
|
||||
# include the preamble in the hash so we invalidate if it changes
|
||||
preamble_hash = _hash_string(load_preamble(language, session, "test"))
|
||||
|
||||
code_blocks_for_session = [preamble_hash, *extract_code_blocks_for_session(language, session, content)]
|
||||
|
||||
return _hash_string("\n".join(code_blocks_for_session))
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Generated
+60
-6
@@ -1016,12 +1016,12 @@ version = "0.4.6"
|
||||
description = "Cross-platform colored terminal text."
|
||||
optional = false
|
||||
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
|
||||
groups = ["docs", "test"]
|
||||
groups = ["main", "docs", "test"]
|
||||
files = [
|
||||
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
|
||||
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
|
||||
]
|
||||
markers = {docs = "python_version <= \"3.11\" or python_version >= \"3.12\"", test = "(platform_system == \"Windows\" or sys_platform == \"win32\" or os_name == \"nt\") and (python_version <= \"3.11\" or python_version >= \"3.12\")"}
|
||||
markers = {main = "sys_platform == \"win32\" and (python_version <= \"3.11\" or python_version >= \"3.12\")", docs = "python_version <= \"3.11\" or python_version >= \"3.12\"", test = "(platform_system == \"Windows\" or sys_platform == \"win32\" or os_name == \"nt\") and (python_version <= \"3.11\" or python_version >= \"3.12\")"}
|
||||
|
||||
[[package]]
|
||||
name = "coloredlogs"
|
||||
@@ -1416,7 +1416,7 @@ version = "1.2.2"
|
||||
description = "Backport of PEP 654 (exception groups)"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
groups = ["docs", "test"]
|
||||
groups = ["main", "docs", "test"]
|
||||
markers = "python_version < \"3.11\""
|
||||
files = [
|
||||
{file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"},
|
||||
@@ -2462,6 +2462,19 @@ enabler = ["pytest-enabler (>=2.2)"]
|
||||
test = ["jaraco.test (>=5.4)", "pytest (>=6,!=8.1.*)", "zipp (>=3.17)"]
|
||||
type = ["pytest-mypy"]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.0.0"
|
||||
description = "brain-dead simple config-ini parsing"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
groups = ["main"]
|
||||
markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
|
||||
files = [
|
||||
{file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"},
|
||||
{file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipykernel"
|
||||
version = "6.29.5"
|
||||
@@ -5234,7 +5247,7 @@ version = "24.2"
|
||||
description = "Core utilities for Python packages"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["docs", "test"]
|
||||
groups = ["main", "docs", "test"]
|
||||
markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
|
||||
files = [
|
||||
{file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"},
|
||||
@@ -5522,6 +5535,23 @@ docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.0.2)", "sphinx-a
|
||||
test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.2)", "pytest-cov (>=5)", "pytest-mock (>=3.14)"]
|
||||
type = ["mypy (>=1.11.2)"]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.5.0"
|
||||
description = "plugin and hook calling mechanisms for python"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
|
||||
files = [
|
||||
{file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"},
|
||||
{file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
dev = ["pre-commit", "tox"]
|
||||
testing = ["pytest", "pytest-benchmark"]
|
||||
|
||||
[[package]]
|
||||
name = "posthog"
|
||||
version = "3.12.1"
|
||||
@@ -6382,6 +6412,30 @@ files = [
|
||||
[package.extras]
|
||||
dev = ["build", "flake8", "mypy", "pytest", "twine"]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "8.3.4"
|
||||
description = "pytest: simple powerful testing with Python"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
markers = "python_version <= \"3.11\" or python_version >= \"3.12\""
|
||||
files = [
|
||||
{file = "pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6"},
|
||||
{file = "pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
colorama = {version = "*", markers = "sys_platform == \"win32\""}
|
||||
exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""}
|
||||
iniconfig = "*"
|
||||
packaging = "*"
|
||||
pluggy = ">=1.5,<2"
|
||||
tomli = {version = ">=1", markers = "python_version < \"3.11\""}
|
||||
|
||||
[package.extras]
|
||||
dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"]
|
||||
|
||||
[[package]]
|
||||
name = "python-dateutil"
|
||||
version = "2.9.0.post0"
|
||||
@@ -7755,7 +7809,7 @@ version = "2.2.1"
|
||||
description = "A lil' TOML parser"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["docs", "test"]
|
||||
groups = ["main", "docs", "test"]
|
||||
markers = "python_version < \"3.11\""
|
||||
files = [
|
||||
{file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"},
|
||||
@@ -8634,4 +8688,4 @@ type = ["pytest-mypy"]
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = "^3.10"
|
||||
content-hash = "06debb82135affdb2baf1fdcc028c062c236121508d787588cd0de1db2da11e4"
|
||||
content-hash = "cbcc30bb9bdead3545070eedf47fb83fe971d4914613e2188323fb3e91035e76"
|
||||
|
||||
@@ -10,6 +10,7 @@ readme = "README.md"
|
||||
python = "^3.10"
|
||||
aiohappyeyeballs = "2.4.3"
|
||||
hub = "^3.0.1"
|
||||
pytest = "^8.3.4"
|
||||
|
||||
[tool.poetry.group.docs.dependencies]
|
||||
langgraph = { path = "../libs/langgraph/", develop = true }
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import re
|
||||
from textwrap import dedent
|
||||
|
||||
import pytest
|
||||
from _scripts.hook_state import hook_state
|
||||
from _scripts.notebook_hooks import handle_vcr_setup
|
||||
from _scripts.setup_vcr import extract_code_blocks_for_session, get_hash_for_session
|
||||
|
||||
INITIAL_DOCUMENT_CONTENT = dedent(
|
||||
"""
|
||||
Blah blah blah
|
||||
|
||||
```python exec="on" source="above" session="1" result="ansi"
|
||||
print("FIRST_CODE_BLOCK")
|
||||
```
|
||||
|
||||
Blah blah blah!
|
||||
|
||||
```python exec="on" source="above" session="1" result="ansi"
|
||||
print("SECOND_CODE_BLOCK")
|
||||
```
|
||||
|
||||
more blah blah blah
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"replace_string",
|
||||
[
|
||||
"FIRST_CODE_BLOCK",
|
||||
"SECOND_CODE_BLOCK",
|
||||
],
|
||||
)
|
||||
def test_changing_block_in_session_invalidates_hash(replace_string: str):
|
||||
hook_state['document_filename'] = 'test.md'
|
||||
hook_state['document_content'] = INITIAL_DOCUMENT_CONTENT
|
||||
|
||||
code = "print('Hello, world!')"
|
||||
|
||||
result1 = handle_vcr_setup(
|
||||
formatter=lambda **kwargs: None,
|
||||
language="python",
|
||||
session="1",
|
||||
id="test",
|
||||
code=code,
|
||||
md=None,
|
||||
extra={},
|
||||
)
|
||||
|
||||
cassette_init_expr = re.compile(r"^_cassette = HashedCassette\('[^']+', '(?P<hash>[^']+)'\)$")
|
||||
assert result1['transform_source']
|
||||
execute_source, display_source = result1['transform_source'](code)
|
||||
assert display_source == code
|
||||
cassette_init_line = [line for line in execute_source.splitlines() if line.startswith("_cassette = HashedCassette(")][0]
|
||||
assert cassette_init_line
|
||||
match = cassette_init_expr.match(cassette_init_line)
|
||||
assert match
|
||||
hash_ = str(match.group('hash'))
|
||||
|
||||
# change the content of the second block of code
|
||||
hook_state['document_content'] = INITIAL_DOCUMENT_CONTENT.replace(replace_string, "world")
|
||||
assert hook_state['document_content'] != INITIAL_DOCUMENT_CONTENT
|
||||
|
||||
result2 = handle_vcr_setup(
|
||||
formatter=lambda **kwargs: None,
|
||||
language="python",
|
||||
session="1",
|
||||
id="test",
|
||||
code=code,
|
||||
md=None,
|
||||
extra={},
|
||||
)
|
||||
|
||||
assert result2['transform_source']
|
||||
execute_source, display_source = result2['transform_source'](code)
|
||||
assert display_source == code
|
||||
cassette_init_line = [line for line in execute_source.splitlines() if line.startswith("_cassette = HashedCassette(")][0]
|
||||
assert cassette_init_line
|
||||
match = cassette_init_expr.match(cassette_init_line)
|
||||
assert match
|
||||
|
||||
# this is the important part
|
||||
assert str(match.group('hash')) != hash_
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"replace_string",
|
||||
[
|
||||
"FIRST_CODE_BLOCK",
|
||||
"SECOND_CODE_BLOCK",
|
||||
],
|
||||
)
|
||||
def test_get_hash_for_session(replace_string: str):
|
||||
hash_ = get_hash_for_session(
|
||||
language="python",
|
||||
session="1",
|
||||
content=INITIAL_DOCUMENT_CONTENT,
|
||||
)
|
||||
|
||||
content = INITIAL_DOCUMENT_CONTENT.replace(replace_string, "world")
|
||||
assert content != INITIAL_DOCUMENT_CONTENT
|
||||
assert get_hash_for_session(
|
||||
language="python",
|
||||
session="1",
|
||||
content=INITIAL_DOCUMENT_CONTENT.replace(replace_string, "world"),
|
||||
) != hash_
|
||||
|
||||
def test_get_code_blocks_for_session():
|
||||
code_blocks = extract_code_blocks_for_session(
|
||||
language="python",
|
||||
session="1",
|
||||
content=INITIAL_DOCUMENT_CONTENT,
|
||||
)
|
||||
assert code_blocks
|
||||
assert len(code_blocks) == 2
|
||||
assert code_blocks[0] == 'print("FIRST_CODE_BLOCK")'
|
||||
assert code_blocks[1] == 'print("SECOND_CODE_BLOCK")'
|
||||
@@ -111,7 +111,7 @@ print("Hello, World!")
|
||||
|
||||
|
||||
END_TO_END_INPUT_HIGHLIGHT_1_EXPECT = """\
|
||||
```python exec="on" source="below" hl_lines="2" path="dummy.md"
|
||||
```python exec="on" source="below" hl_lines="2"
|
||||
print("Hello, World!")
|
||||
print("Hello, World!")
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user