From f4e9cdbb9c1d56195c04e3142e01b7b77198085d Mon Sep 17 00:00:00 2001 From: Russell Jurney Date: Sat, 15 Feb 2025 20:58:48 -0800 Subject: [PATCH 01/70] Converted tests to pytest. Build a Python package. Update requirements.txt and split out requirements-dev.txt. Version bumps. --- .github/workflows/python-ci.yml | 7 +- .gitignore | 10 + Dockerfile | 10 +- VERSION | 0 build.sbt | 2 +- docs/_config.yml | 2 +- python/.gitignore | 4 - python/MANIFEST.in | 4 + python/graphframes/tests.py | 405 ++++++++++++++++++-------------- python/requirements-dev.txt | 6 + python/requirements.txt | 5 +- python/run-tests.sh | 17 +- python/setup.cfg | 44 +++- python/setup.py | 37 ++- version.sbt | 2 +- 15 files changed, 342 insertions(+), 213 deletions(-) delete mode 100644 VERSION delete mode 100644 python/.gitignore create mode 100644 python/requirements-dev.txt diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml index 8b84d6d82..36b6b97e7 100644 --- a/.github/workflows/python-ci.yml +++ b/.github/workflows/python-ci.yml @@ -7,8 +7,8 @@ jobs: matrix: include: - spark-version: 3.5.4 - scala-version: 2.12.18 - python-version: 3.9.19 + scala-version: 2.12.20 + python-version: 3.11.11 runs-on: ubuntu-22.04 env: # define Java options for both official sbt and sbt-extras @@ -35,8 +35,11 @@ jobs: run: | python -m pip install --upgrade pip wheel pip install -r ./python/requirements.txt + pip install -r ./python/requirements-dev.txt pip install pyspark==${{ matrix.spark-version }} - name: Test run: | + python python/setup.py install + python python/setup.py bdist_wheel export SPARK_HOME=$(python -c "import os; from importlib.util import find_spec; print(os.path.join(os.path.dirname(find_spec('pyspark').origin)))") ./python/run-tests.sh diff --git a/.gitignore b/.gitignore index a07973c1e..dcbde8186 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,13 @@ project/plugins/project/ # Mac *.DS_Store +.vscode + +# Python specific +python/build +python/dist +build/lib +python/graphframes.egg-info +python/graphframes/tutorials/data +python/docs/_build +python/docs/_site diff --git a/Dockerfile b/Dockerfile index 1c4430912..b9fe8c528 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,16 +1,16 @@ FROM ubuntu:22.04 -ARG PYTHON_VERSION=3.8 +ARG PYTHON_VERSION=3.9 ARG DEBIAN_FRONTEND=noninteractive RUN apt-get update && \ - apt-get install -y wget bzip2 build-essential openjdk-8-jdk ssh sudo && \ + apt-get install -y wget bzip2 build-essential openjdk-11-jdk ssh sudo && \ apt-get clean # Install Spark and update env variables. -ENV SCALA_VERSION 2.12.17 -ENV SPARK_VERSION "3.4.1" -ENV SPARK_BUILD "spark-${SPARK_VERSION}-bin-hadoop3.2" +ENV SCALA_VERSION 2.12.20 +ENV SPARK_VERSION "3.5.4" +ENV SPARK_BUILD "spark-${SPARK_VERSION}-bin-hadoop3" ENV SPARK_BUILD_URL "https://dist.apache.org/repos/dist/release/spark/spark-${SPARK_VERSION}/${SPARK_BUILD}.tgz" RUN wget --quiet "$SPARK_BUILD_URL" -O /tmp/spark.tgz && \ tar -C /opt -xf /tmp/spark.tgz && \ diff --git a/VERSION b/VERSION deleted file mode 100644 index e69de29bb..000000000 diff --git a/build.sbt b/build.sbt index 061901717..63168c57d 100644 --- a/build.sbt +++ b/build.sbt @@ -3,7 +3,7 @@ import ReleaseTransformations._ lazy val sparkVer = sys.props.getOrElse("spark.version", "3.5.4") lazy val sparkBranch = sparkVer.substring(0, 3) lazy val defaultScalaVer = sparkBranch match { - case "3.5" => "2.12.18" + case "3.5" => "2.12.20" case _ => throw new IllegalArgumentException(s"Unsupported Spark version: $sparkVer.") } lazy val scalaVer = sys.props.getOrElse("scala.version", defaultScalaVer) diff --git a/docs/_config.yml b/docs/_config.yml index 4c1ab075c..379fc242f 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -13,7 +13,7 @@ include: # These allow the documentation to be updated with newer releases # of Spark, Scala, and Mesos. -GRAPHFRAMES_VERSION: 0.8.4 +GRAPHFRAMES_VERSION: 0.8.5 #SCALA_BINARY_VERSION: "2.10" #SCALA_VERSION: "2.10.4" #MESOS_VERSION: 0.21.0 diff --git a/python/.gitignore b/python/.gitignore deleted file mode 100644 index 81410ca55..000000000 --- a/python/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -*.pyc -docs/_build/ -build/ -dist/ diff --git a/python/MANIFEST.in b/python/MANIFEST.in index 73eaf8ba2..4eb0ee5af 100644 --- a/python/MANIFEST.in +++ b/python/MANIFEST.in @@ -2,3 +2,7 @@ # https://github.com/pypa/sampleproject/blob/master/MANIFEST.in # For more details about the MANIFEST file, you may read the docs at # https://docs.python.org/2/distutils/sourcedist.html#the-manifest-in-template +recursive-include python/graphframes *.py +recursive-exclude * __pycache__ +recursive-exclude * *.pyc +include graphframes/tutorials/data/.exists diff --git a/python/graphframes/tests.py b/python/graphframes/tests.py index 9a7ad1371..259435759 100644 --- a/python/graphframes/tests.py +++ b/python/graphframes/tests.py @@ -15,63 +15,72 @@ # limitations under the License. # -import sys +import os import tempfile import shutil import re -if sys.version_info[:2] <= (2, 6): - try: - import unittest2 as unittest - except ImportError: - sys.stderr.write('Please install unittest2 to test with Python 2.6 or earlier') - sys.exit(1) -else: - import unittest - -from pyspark import SparkContext -from pyspark.sql import functions as sqlfunctions, SparkSession +import pytest +from pyspark import SparkConf, SparkContext +from pyspark.sql import functions as F, SparkSession from .graphframe import GraphFrame, Pregel, _java_api, _from_java_gf from .lib import AggregateMessages as AM from .examples import Graphs, BeliefPropagation + +VERSION = open("version.sbt").read().strip() + + +@pytest.fixture(scope="class", autouse=True) +def set_spark(request, spark_session): + request.cls.spark = spark_session + + +@pytest.mark.usefixtures("set_spark") class GraphFrameTestUtils(object): @classmethod def parse_spark_version(cls, version_str): - """ take an input version string - return version items in a dictionary + """take an input version string + return version items in a dictionary """ - _sc_ver_patt = r'(\d+)\.(\d+)(\.(\d+)(-(.+))?)?' + _sc_ver_patt = r"(\d+)\.(\d+)(\.(\d+)(-(.+))?)?" m = re.match(_sc_ver_patt, version_str) if not m: - raise TypeError("version {} shoud be in ..".format(version_str)) + raise TypeError( + "version {} shoud be in ..".format(version_str) + ) version_info = {} try: - version_info['major'] = int(m.group(1)) + version_info["major"] = int(m.group(1)) except: raise TypeError("invalid minor version") try: - version_info['minor'] = int(m.group(2)) + version_info["minor"] = int(m.group(2)) except: raise TypeError("invalid major version") try: - version_info['maintenance'] = int(m.group(4)) + version_info["maintenance"] = int(m.group(4)) except: - version_info['maintenance'] = 0 + version_info["maintenance"] = 0 try: - version_info['special'] = m.group(6) + version_info["special"] = m.group(6) except: pass return version_info @classmethod def createSparkContext(cls): - cls.sc = sc = SparkContext('local[4]', "GraphFramesTests") + cls.conf = SparkConf().setAppName("GraphFramesTests") + cls.conf.set( + "spark.submit.pyFiles", + os.path.abspath("python/dist/graphframes-{VERSION}-py3-none-any.whl"), + ) + cls.sc = SparkContext(master="local[4]", appName="GraphFramesTests", conf=cls.conf) cls.checkpointDir = tempfile.mkdtemp() cls.sc.setCheckpointDir(cls.checkpointDir) - cls.spark_version = cls.parse_spark_version(sc.version) + cls.spark_version = cls.parse_spark_version(cls.sc.version) @classmethod def stopSparkContext(cls): @@ -81,10 +90,10 @@ def stopSparkContext(cls): @classmethod def spark_at_least_of_version(cls, version_str): - assert hasattr(cls, 'spark_version') + assert hasattr(cls, "spark_version") required_version = cls.parse_spark_version(version_str) spark_version = cls.spark_version - for _name in ['major', 'minor', 'maintenance']: + for _name in ["major", "minor", "maintenance"]: sc_ver = spark_version[_name] req_ver = required_version[_name] if sc_ver != req_ver: @@ -92,28 +101,31 @@ def spark_at_least_of_version(cls, version_str): # All major.minor.maintenance equal return True -def setUpModule(): - GraphFrameTestUtils.createSparkContext() -def tearDownModule(): +@pytest.fixture(scope="module", autouse=True) +def spark_context(): + GraphFrameTestUtils.createSparkContext() + yield GraphFrameTestUtils.stopSparkContext() -class GraphFrameTestCase(unittest.TestCase): +@pytest.fixture(scope="class") +def spark_session(): + # Create a SparkSession with a smaller number of shuffle partitions. + spark = ( + SparkSession(GraphFrameTestUtils.sc) + .builder.config("spark.sql.shuffle.partitions", 4) + .getOrCreate() + ) + yield spark + # No explicit stop; SparkContext shutdown will clean up. - @classmethod - def setUpClass(cls): - # Small tests run much faster with spark.sql.shuffle.partitions = 4 - cls.spark = SparkSession(GraphFrameTestUtils.sc).builder.config('spark.sql.shuffle.partitions', 4).getOrCreate() - - @classmethod - def tearDownClass(cls): - cls.spark = None +@pytest.mark.usefixtures("set_spark") +class GraphFrameTest: -class GraphFrameTest(GraphFrameTestCase): - def setUp(self): - super(GraphFrameTest, self).setUp() + def setup_method(self, method): + # Mimic setUp: create a simple GraphFrame instance for each test. localVertices = [(1, "A"), (2, "B"), (3, "C")] localEdges = [(1, 2, "love"), (2, 1, "hate"), (2, 3, "follow")] v = self.spark.createDataFrame(localVertices, ["id", "name"]) @@ -123,28 +135,38 @@ def setUp(self): def test_spark_version_check(self): gtu = GraphFrameTestUtils gtu.spark_version = gtu.parse_spark_version("2.0.2") - self.assertTrue(gtu.spark_at_least_of_version("1.7")) - self.assertTrue(gtu.spark_at_least_of_version("2.0")) - self.assertTrue(gtu.spark_at_least_of_version("2.0.1")) - self.assertTrue(gtu.spark_at_least_of_version("2.0.2")) - self.assertFalse(gtu.spark_at_least_of_version("2.0.3")) - self.assertFalse(gtu.spark_at_least_of_version("2.1")) + + assert gtu.spark_at_least_of_version("1.7") + assert gtu.spark_at_least_of_version("2.0") + assert gtu.spark_at_least_of_version("2.0.1") + assert gtu.spark_at_least_of_version("2.0.2") + assert not gtu.spark_at_least_of_version("2.0.3") + assert not gtu.spark_at_least_of_version("2.1") def test_construction(self): g = self.g - vertexIDs = map(lambda x: x[0], g.vertices.select("id").collect()) + vertexIDs = [row[0] for row in g.vertices.select("id").collect()] assert sorted(vertexIDs) == [1, 2, 3] - edgeActions = map(lambda x: x[0], g.edges.select("action").collect()) + + edgeActions = [row[0] for row in g.edges.select("action").collect()] assert sorted(edgeActions) == ["follow", "hate", "love"] - tripletsFirst = list(map(lambda x: (x[0][1], x[1][1], x[2][2]), - g.triplets.sort("src.id").select("src", "dst", "edge").take(1))) + + tripletsFirst = list( + map( + lambda x: (x[0][1], x[1][1], x[2][2]), + g.triplets.sort("src.id").select("src", "dst", "edge").take(1), + ) + ) assert tripletsFirst == [("A", "B", "love")], tripletsFirst + # Try with invalid vertices and edges DataFrames v_invalid = self.spark.createDataFrame( - [(1, "A"), (2, "B"), (3, "C")], ["invalid_colname_1", "invalid_colname_2"]) + [(1, "A"), (2, "B"), (3, "C")], ["invalid_colname_1", "invalid_colname_2"] + ) e_invalid = self.spark.createDataFrame( - [(1, 2), (2, 3), (3, 1)], ["invalid_colname_3", "invalid_colname_4"]) - with self.assertRaises(ValueError): + [(1, 2), (2, 3), (3, 1)], ["invalid_colname_3", "invalid_colname_4"] + ) + with pytest.raises(ValueError): GraphFrame(v_invalid, e_invalid) def test_cache(self): @@ -155,17 +177,17 @@ def test_cache(self): def test_degrees(self): g = self.g outDeg = g.outDegrees - self.assertSetEqual(set(outDeg.columns), {"id", "outDegree"}) + assert set(outDeg.columns) == {"id", "outDegree"} inDeg = g.inDegrees - self.assertSetEqual(set(inDeg.columns), {"id", "inDegree"}) + assert set(inDeg.columns) == {"id", "inDegree"} deg = g.degrees - self.assertSetEqual(set(deg.columns), {"id", "degree"}) + assert set(deg.columns) == {"id", "degree"} def test_motif_finding(self): g = self.g motifs = g.find("(a)-[e]->(b)") assert motifs.count() == 3 - self.assertSetEqual(set(motifs.columns), {"a", "e", "b"}) + assert set(motifs.columns) == {"a", "e", "b"} def test_filterVertices(self): g = self.g @@ -178,8 +200,8 @@ def test_filterVertices(self): e2 = g2.edges.select("src", "dst", "action").collect() assert len(v2) == len(expected_v) assert len(e2) == len(expected_e) - self.assertSetEqual(set(v2), set(expected_v)) - self.assertSetEqual(set(e2), set(expected_e)) + assert set(v2) == set(expected_v) + assert set(e2) == set(expected_e) def test_filterEdges(self): g = self.g @@ -192,8 +214,8 @@ def test_filterEdges(self): e2 = g2.edges.select("src", "dst", "action").collect() assert len(v2) == len(expected_v) assert len(e2) == len(expected_e) - self.assertSetEqual(set(v2), set(expected_v)) - self.assertSetEqual(set(e2), set(expected_e)) + assert set(v2) == set(expected_v) + assert set(e2) == set(expected_e) def test_dropIsolatedVertices(self): g = self.g @@ -204,74 +226,93 @@ def test_dropIsolatedVertices(self): expected_e = [(2, 3, "follow")] assert len(v2) == len(expected_v) assert len(e2) == len(expected_e) - self.assertSetEqual(set(v2), set(expected_v)) - self.assertSetEqual(set(e2), set(expected_e)) + assert set(v2) == set(expected_v) + assert set(e2) == set(expected_e) def test_bfs(self): g = self.g paths = g.bfs("name='A'", "name='C'") - self.assertEqual(paths.count(), 1) - self.assertEqual(paths.select("v1.name").head()[0], "B") + assert paths.count() == 1 + # Expecting that the first intermediary vertex in the BFS is "B" + assert paths.select("v1.name").head()[0] == "B" + paths2 = g.bfs("name='A'", "name='C'", edgeFilter="action!='follow'") - self.assertEqual(paths2.count(), 0) + assert paths2.count() == 0 + paths3 = g.bfs("name='A'", "name='C'", maxPathLength=1) - self.assertEqual(paths3.count(), 0) + assert paths3.count() == 0 -class PregelTest(GraphFrameTestCase): - def setUp(self): - super(PregelTest, self).setUp() +@pytest.mark.usefixtures("set_spark") +class TestPregel: def test_page_rank(self): - from pyspark.sql.functions import coalesce, col, lit, sum, when - edges = self.spark.createDataFrame([[0, 1], - [1, 2], - [2, 4], - [2, 0], - [3, 4], # 3 has no in-links - [4, 0], - [4, 2]], ["src", "dst"]) + # Create an edge DataFrame; note that vertex 3 has no in-links. + edges = self.spark.createDataFrame( + [[0, 1], [1, 2], [2, 4], [2, 0], [3, 4], [4, 0], [4, 2]], + ["src", "dst"], + ) edges.cache() + + # Create a vertex DataFrame and count vertices. vertices = self.spark.createDataFrame([[0], [1], [2], [3], [4]], ["id"]) numVertices = vertices.count() + + # Get the outDegrees DataFrame from a GraphFrame built on the original vertices and edges. vertices = GraphFrame(vertices, edges).outDegrees vertices.cache() + + # Construct a new GraphFrame with the updated vertices DataFrame. graph = GraphFrame(vertices, edges) alpha = 0.15 - ranks = graph.pregel \ - .setMaxIter(5) \ - .withVertexColumn("rank", lit(1.0 / numVertices), - coalesce(Pregel.msg(), - lit(0.0)) * lit(1.0 - alpha) + lit(alpha / numVertices)) \ - .sendMsgToDst(Pregel.src("rank") / Pregel.src("outDegree")) \ - .aggMsgs(sum(Pregel.msg())) \ + + # Run PageRank via Pregel. + ranks = ( + graph.pregel.setMaxIter(5) + .withVertexColumn( + "rank", + F.lit(1.0 / numVertices), + F.coalesce(Pregel.msg(), F.lit(0.0)) * F.lit(1.0 - alpha) + + F.lit(alpha / numVertices), + ) + .sendMsgToDst(Pregel.src("rank") / Pregel.src("outDegree")) + .aggMsgs(F.sum(Pregel.msg())) .run() + ) + + # Collect and sort results. resultRows = ranks.sort(ranks.id).collect() - result = map(lambda x: x.rank, resultRows) + result = list(map(lambda x: x.rank, resultRows)) expected = [0.245, 0.224, 0.303, 0.03, 0.197] + + # Compare each result with its expected value using a tolerance of 1e-3. for a, b in zip(result, expected): - self.assertAlmostEqual(a, b, delta = 1e-3) + assert a == pytest.approx(b, abs=1e-3) + +@pytest.mark.usefixtures("set_spark") +class TestGraphFrameLib: -class GraphFrameLibTest(GraphFrameTestCase): - def setUp(self): - super(GraphFrameLibTest, self).setUp() + def setup_method(self, method): + # Set up the Java API instance for each test. self.japi = _java_api(self.spark._sc) - def _hasCols(self, graph, vcols = [], ecols = []): - map(lambda c: self.assertIn(c, graph.vertices.columns), vcols) - map(lambda c: self.assertIn(c, graph.edges.columns), ecols) + def _hasCols(self, graph, vcols=[], ecols=[]): + for c in vcols: + assert c in graph.vertices.columns, f"Vertex DataFrame missing column: {c}" + for c in ecols: + assert c in graph.edges.columns, f"Edge DataFrame missing column: {c}" - def _df_hasCols(self, vertices, vcols = []): - map(lambda c: self.assertIn(c, vertices.columns), vcols) + def _df_hasCols(self, df, vcols=[]): + for c in vcols: + assert c in df.columns, f"DataFrame missing column: {c}" def _graph(self, name, *args): """ - Convenience to call one of the example graphs, passing the arguments and wrapping the result back - as a python object. - :param name: the name of the example graph - :param args: all the required arguments, without the initial spark session - :return: + Convenience to call one of the example graphs, passing the arguments and wrapping the result as a Python object. + :param name: the name of the example graph. + :param args: all the required arguments (excluding the initial SparkSession). + :return: a GraphFrame object. """ examples = self.japi.examples() jgraph = getattr(examples, name)(*args) @@ -281,83 +322,79 @@ def test_aggregate_messages(self): g = self._graph("friends") # For each user, sum the ages of the adjacent users, # plus 1 for the src's sum if the edge is "friend". - sendToSrc = ( - AM.dst['age'] + - sqlfunctions.when( - AM.edge['relationship'] == 'friend', - sqlfunctions.lit(1) - ).otherwise(0)) - sendToDst = AM.src['age'] + sendToSrc = AM.dst["age"] + F.when(AM.edge["relationship"] == "friend", F.lit(1)).otherwise( + 0 + ) + sendToDst = AM.src["age"] agg = g.aggregateMessages( - sqlfunctions.sum(AM.msg).alias('summedAges'), - sendToSrc=sendToSrc, - sendToDst=sendToDst) - # Run the aggregation again providing SQL expressions as String instead. + F.sum(AM.msg).alias("summedAges"), sendToSrc=sendToSrc, sendToDst=sendToDst + ) + # Run the aggregation again using SQL expressions as Strings. agg2 = g.aggregateMessages( "sum(MSG) AS `summedAges`", sendToSrc="(dst['age'] + CASE WHEN (edge['relationship'] = 'friend') THEN 1 ELSE 0 END)", - sendToDst="src['age']") - # Convert agg and agg2 to a mapping from id to the aggregated message. - aggMap = {id_: s for id_, s in agg.select('id', 'summedAges').collect()} - agg2Map = {id_: s for id_, s in agg2.select('id', 'summedAges').collect()} - # Compute the truth via brute force. - user2age = {id_: age for id_, age in g.vertices.select('id', 'age').collect()} + sendToDst="src['age']", + ) + # Build mappings from id to the aggregated message. + aggMap = {row.id: row.summedAges for row in agg.select("id", "summedAges").collect()} + agg2Map = {row.id: row.summedAges for row in agg2.select("id", "summedAges").collect()} + # Compute the expected aggregation via brute force. + user2age = {row.id: row.age for row in g.vertices.select("id", "age").collect()} trueAgg = {} - for src, dst, rel in g.edges.select("src", "dst", "relationship").collect(): - trueAgg[src] = trueAgg.get(src, 0) + user2age[dst] + (1 if rel == 'friend' else 0) + for row in g.edges.select("src", "dst", "relationship").collect(): + src, dst, rel = row.src, row.dst, row.relationship + trueAgg[src] = trueAgg.get(src, 0) + user2age[dst] + (1 if rel == "friend" else 0) trueAgg[dst] = trueAgg.get(dst, 0) + user2age[src] - # Compare if the agg mappings match the brute force mapping - self.assertEqual(aggMap, trueAgg) - self.assertEqual(agg2Map, trueAgg) - # Check that TypeError is raises with messages of wrong type - with self.assertRaises(TypeError): + # Verify both aggregations match the expected results. + assert aggMap == trueAgg, f"aggMap {aggMap} does not equal expected {trueAgg}" + assert agg2Map == trueAgg, f"agg2Map {agg2Map} does not equal expected {trueAgg}" + # Check that passing a wrong type for messages raises a TypeError. + with pytest.raises(TypeError): g.aggregateMessages( - "sum(MSG) AS `summedAges`", - sendToSrc=object(), - sendToDst="src['age']") - with self.assertRaises(TypeError): + "sum(MSG) AS `summedAges`", sendToSrc=object(), sendToDst="src['age']" + ) + with pytest.raises(TypeError): g.aggregateMessages( - "sum(MSG) AS `summedAges`", - sendToSrc=dst['age'], - sendToDst=object()) + "sum(MSG) AS `summedAges`", sendToSrc=F.col("dst")["age"], sendToDst=object() + ) def test_connected_components(self): - v = self.spark.createDataFrame([ - (0, "a", "b")], ["id", "vattr", "gender"]) + v = self.spark.createDataFrame([(0, "a", "b")], ["id", "vattr", "gender"]) e = self.spark.createDataFrame([(0, 0, 1)], ["src", "dst", "test"]).filter("src > 10") g = GraphFrame(v, e) comps = g.connectedComponents() - self._df_hasCols(comps, vcols=['id', 'component', 'vattr', 'gender']) - self.assertEqual(comps.count(), 1) + self._df_hasCols(comps, vcols=["id", "component", "vattr", "gender"]) + assert comps.count() == 1 def test_connected_components2(self): v = self.spark.createDataFrame([(0, "a0", "b0"), (1, "a1", "b1")], ["id", "A", "B"]) e = self.spark.createDataFrame([(0, 1, "a01", "b01")], ["src", "dst", "A", "B"]) g = GraphFrame(v, e) comps = g.connectedComponents() - self._df_hasCols(comps, vcols=['id', 'component', 'A', 'B']) - self.assertEqual(comps.count(), 2) + self._df_hasCols(comps, vcols=["id", "component", "A", "B"]) + assert comps.count() == 2 def test_connected_components_friends(self): g = self._graph("friends") - comps_tests = [] - comps_tests += [g.connectedComponents()] - comps_tests += [g.connectedComponents(broadcastThreshold=1)] - comps_tests += [g.connectedComponents(checkpointInterval=0)] - comps_tests += [g.connectedComponents(checkpointInterval=10)] - comps_tests += [g.connectedComponents(algorithm="graphx")] + comps_tests = [ + g.connectedComponents(), + g.connectedComponents(broadcastThreshold=1), + g.connectedComponents(checkpointInterval=0), + g.connectedComponents(checkpointInterval=10), + g.connectedComponents(algorithm="graphx"), + ] for c in comps_tests: - self.assertEqual(c.groupBy("component").count().count(), 2) + assert c.groupBy("component").count().count() == 2 def test_label_progagation(self): n = 5 g = self._graph("twoBlobs", n) labels = g.labelPropagation(maxIter=4 * n) labels1 = labels.filter("id < 5").select("label").collect() - all1 = set([x.label for x in labels1]) + all1 = {row.label for row in labels1} assert len(all1) == 1 labels2 = labels.filter("id >= 5").select("label").collect() - all2 = set([x.label for x in labels2]) + all2 = {row.label for row in labels2} assert len(all2) == 1 assert all1 != all2 @@ -367,7 +404,7 @@ def test_page_rank(self): resetProb = 0.15 errorTol = 1.0e-5 pr = g.pageRank(resetProb, tol=errorTol) - self._hasCols(pr, vcols=['id', 'pagerank'], ecols=['src', 'dst', 'weight']) + self._hasCols(pr, vcols=["id", "pagerank"], ecols=["src", "dst", "weight"]) def test_parallel_personalized_page_rank(self): n = 100 @@ -376,31 +413,34 @@ def test_parallel_personalized_page_rank(self): maxIter = 15 sourceIds = [1, 2, 3, 4] pr = g.parallelPersonalizedPageRank(resetProb, sourceIds=sourceIds, maxIter=maxIter) - self._hasCols(pr, vcols=['id', 'pageranks'], ecols=['src', 'dst', 'weight']) + self._hasCols(pr, vcols=["id", "pageranks"], ecols=["src", "dst", "weight"]) def test_shortest_paths(self): edges = [(1, 2), (1, 5), (2, 3), (2, 5), (3, 4), (4, 5), (4, 6)] + # Create bidirectional edges. all_edges = [z for (a, b) in edges for z in [(a, b), (b, a)]] - edges = self.spark.createDataFrame(all_edges, ["src", "dst"]) + edgesDF = self.spark.createDataFrame(all_edges, ["src", "dst"]) vertices = self.spark.createDataFrame([(i,) for i in range(1, 7)], ["id"]) - g = GraphFrame(vertices, edges) + g = GraphFrame(vertices, edgesDF) landmarks = [1, 4] v2 = g.shortestPaths(landmarks) self._df_hasCols(v2, vcols=["id", "distances"]) def test_svd_plus_plus(self): g = self._graph("ALSSyntheticData") - (v2, cost) = g.svdPlusPlus() - self._df_hasCols(v2, vcols=['id', 'column1', 'column2', 'column3', 'column4']) + v2, cost = g.svdPlusPlus() + self._df_hasCols(v2, vcols=["id", "column1", "column2", "column3", "column4"]) def test_strongly_connected_components(self): - # Simple island test + # Simple island test. vertices = self.spark.createDataFrame([(i,) for i in range(1, 6)], ["id"]) edges = self.spark.createDataFrame([(7, 8)], ["src", "dst"]) g = GraphFrame(vertices, edges) c = g.stronglyConnectedComponents(5) for row in c.collect(): - self.assertEqual(row.id, row.component) + assert ( + row.id == row.component + ), f"Vertex {row.id} not equal to its component {row.component}" def test_triangle_counts(self): edges = self.spark.createDataFrame([(0, 1), (1, 2), (2, 0)], ["src", "dst"]) @@ -408,61 +448,66 @@ def test_triangle_counts(self): g = GraphFrame(vertices, edges) c = g.triangleCount() for row in c.select("id", "count").collect(): - self.assertEqual(row.asDict()['count'], 1) - + assert row.asDict()["count"] == 1, f"Triangle count for vertex {row.id} is not 1" + def test_mutithreaded_sparksession_usage(self): - # Test that we can use the GraphFrame API from multiple threads + # Test that the GraphFrame API works correctly from multiple threads. localVertices = [(1, "A"), (2, "B"), (3, "C")] localEdges = [(1, 2, "love"), (2, 1, "hate"), (2, 3, "follow")] v = self.spark.createDataFrame(localVertices, ["id", "name"]) e = self.spark.createDataFrame(localEdges, ["src", "dst", "action"]) - - + exc = None + def run_graphframe() -> None: + nonlocal exc try: GraphFrame(v, e) except Exception as _e: - nonlocal exc exc = _e - + import threading + thread = threading.Thread(target=run_graphframe) thread.start() thread.join() - self.assertIsNone(exc, f"Exception was raised in thread: {exc}") + assert exc is None, f"Exception was raised in thread: {exc}" + +@pytest.mark.usefixtures("set_spark") +class TestGraphFrameExamples: -class GraphFrameExamplesTest(GraphFrameTestCase): - def setUp(self): - super(GraphFrameExamplesTest, self).setUp() + def setup_method(self, method): + # Set up the Java API instance for use in the tests. self.japi = _java_api(self.spark._sc) def test_belief_propagation(self): - # create graphical model g of size 3 x 3 + # Create a graphical model g of size 3x3. g = Graphs(self.spark).gridIsingModel(3) - # run BP for 5 iterations + # Run Belief Propagation (BP) for 5 iterations. numIter = 5 results = BeliefPropagation.runBPwithGraphFrames(g, numIter) - # check beliefs are valid - for row in results.vertices.select('belief').collect(): - belief = row['belief'] - self.assertTrue( - 0 <= belief <= 1, - msg="Expected belief to be probability in [0,1], but found {}".format(belief)) + # Check that each belief is a valid probability in [0, 1]. + for row in results.vertices.select("belief").collect(): + belief = row["belief"] + assert ( + 0 <= belief <= 1 + ), f"Expected belief to be probability in [0,1], but found {belief}" def test_graph_friends(self): - # construct graph + # Construct the graph. g = Graphs(self.spark).friends() - # check that a GraphFrame instance was returned - self.assertIsInstance(g, GraphFrame) + # Check that the result is an instance of GraphFrame. + assert isinstance(g, GraphFrame) def test_graph_grid_ising_model(self): - # construct graph + # Construct a grid Ising model graph. n = 3 g = Graphs(self.spark).gridIsingModel(n) - # check that all the vertices exist - ids = [v['id'] for v in g.vertices.collect()] + # Collect the vertex ids. + ids = [v["id"] for v in g.vertices.collect()] + # Verify that every expected vertex id appears. for i in range(n): for j in range(n): - self.assertIn('{},{}'.format(i, j), ids) + expected_id = f"{i},{j}" + assert expected_id in ids, f"Vertex {expected_id} not found in {ids}" diff --git a/python/requirements-dev.txt b/python/requirements-dev.txt new file mode 100644 index 000000000..b27da4d73 --- /dev/null +++ b/python/requirements-dev.txt @@ -0,0 +1,6 @@ +pytest==8.3.4 +Sphinx==8.1.3 +flake8==7.1.1 +isort==6.0.0 +mypy==1.14.1 +pre-commit==4.0.1 diff --git a/python/requirements.txt b/python/requirements.txt index efb5ec378..fb73319f2 100644 --- a/python/requirements.txt +++ b/python/requirements.txt @@ -1,3 +1,6 @@ # This file should list any python package dependencies. -nose==1.3.7 +pyspark>=2.0.0 +click==8.1.8 numpy>=1.7 +py7zr==0.22.0 +requests==2.32.3 diff --git a/python/run-tests.sh b/python/run-tests.sh index af4e0a139..dc496e8b0 100755 --- a/python/run-tests.sh +++ b/python/run-tests.sh @@ -38,7 +38,7 @@ echo $pyver LIBS="" for lib in "$SPARK_HOME/python/lib"/*zip ; do - LIBS=$LIBS:$lib + LIBS=$LIBS:$lib done # The current directory of the script. @@ -51,7 +51,7 @@ assembly_path="$DIR/../target/scala-$scala_version_major_minor" echo `ls $assembly_path/graphframes-assembly*.jar` JAR_PATH="" for assembly in $assembly_path/graphframes-assembly*.jar ; do - JAR_PATH=$assembly + JAR_PATH=$assembly done export PYSPARK_SUBMIT_ARGS="--driver-memory 2g --executor-memory 2g --jars $JAR_PATH pyspark-shell " @@ -62,17 +62,7 @@ export PYTHONPATH=$PYTHONPATH:graphframes # Run test suites - -if [[ "$python_major" == "2" ]]; then - - # Horrible hack for spark 1.x: we manually remove some log lines to stay below the 4MB log limit on Travis. - $PYSPARK_DRIVER_PYTHON `which nosetests` -v --all-modules -w $DIR 2>&1 | grep -vE "INFO (ParquetOutputFormat|SparkContext|ContextCleaner|ShuffleBlockFetcherIterator|MapOutputTrackerMaster|TaskSetManager|Executor|MemoryStore|CacheManager|BlockManager|DAGScheduler|PythonRDD|TaskSchedulerImpl|ZippedPartitionsRDD2)"; - -else - - $PYSPARK_DRIVER_PYTHON -m "nose" -v --all-modules -w $DIR 2>&1 | grep -vE "INFO (ParquetOutputFormat|SparkContext|ContextCleaner|ShuffleBlockFetcherIterator|MapOutputTrackerMaster|TaskSetManager|Executor|MemoryStore|CacheManager|BlockManager|DAGScheduler|PythonRDD|TaskSchedulerImpl|ZippedPartitionsRDD2)"; - -fi +$PYSPARK_DRIVER_PYTHON -m "pytest" -v $DIR/graphframes/tests.py 2>&1 | grep -vE "INFO (ParquetOutputFormat|SparkContext|ContextCleaner|ShuffleBlockFetcherIterator|MapOutputTrackerMaster|TaskSetManager|Executor|MemoryStore|CacheManager|BlockManager|DAGScheduler|PythonRDD|TaskSchedulerImpl|ZippedPartitionsRDD2)"; # Exit immediately if the tests fail. # Since we pipe to remove the output, we need to use some horrible BASH features: @@ -80,7 +70,6 @@ fi test ${PIPESTATUS[0]} -eq 0 || exit 1; # Run doc tests - cd "$DIR" $PYSPARK_PYTHON -u ./graphframes/graphframe.py "$@" diff --git a/python/setup.cfg b/python/setup.cfg index f127b08af..02a0d5136 100644 --- a/python/setup.cfg +++ b/python/setup.cfg @@ -1,2 +1,42 @@ -# This file contains the default option values to be used during setup. An -# example can be found at https://github.com/pypa/sampleproject/blob/master/setup.cfg +[metadata] +name = graphframes +version = 0.8.5 +description = GraphFrames: Graph Processing Framework for Apache Spark +long_description = file: ../README.md +long_description_content_type = text/markdown +author = GraphFrames Contributors +author_email = graphframes@googlegroups.com +url = https://pypi.org/project/graphframes-py/ +license = Apache License 2.0 +classifiers = + Development Status :: 4 - Beta + Programming Language :: Python :: 3 + Operating System :: OS Independent + +[options] +packages = find: +package_dir = + = python +include_package_data = True +install_requires = + pyspark>=2.0.0 + click==8.1.8 + numpy>=1.7 + py7zr==0.22.0 + requests==2.32.3 + +[options.packages.find] +where = python + exclude = + tests.py + docs + +[options.extras_require] +dev = + pytest==8.3.4 + Sphinx==8.1.3 + black==25.1.0 + flake8==7.1.1 + isort==6.0.0 + mypy==1.14.1 + pre-commit==3.5.1 diff --git a/python/setup.py b/python/setup.py index 9dad5462e..a91fb629a 100644 --- a/python/setup.py +++ b/python/setup.py @@ -1,2 +1,35 @@ -# Your python setup file. An example can be found at: -# https://github.com/pypa/sampleproject/blob/master/setup.py +from setuptools import setup, find_packages # type: ignore +import os + + +def parse_requirements(filename): + """Load requirements from a pip requirements file.""" + with open(filename, encoding="utf-8") as f: + # Filter out comments and empty lines. + return [line.strip() for line in f if line.strip() and not line.startswith("#")] + + +# Read the long description from the README file. +here = os.path.abspath(os.path.dirname(__file__)) + +# Use requirements.txt to get the list of dependencies. +requirements = parse_requirements(os.path.join(here, "requirements.txt")) + +setup( + name="graphframes", + version=open("version.sbt").read().strip(), # Update this version as needed + description="GraphFrames: Graph Processing Framework for Apache Spark", + long_description=open(os.path.join(f"{here}/..", "README.md"), encoding="utf-8").read(), + long_description_content_type="text/markdown", + author="GraphFrames Contributors", + author_email="graphframes@googlegroups.com", + url="https://pypi.org/project/graphframes-py", + packages=find_packages(where="python"), + package_dir={"": "python"}, + include_package_data=True, # Include non-code files specified in MANIFEST.in + install_requires=requirements, + classifiers=[ + "Programming Language :: Python :: 3", + "Operating System :: OS Independent", + ], +) diff --git a/version.sbt b/version.sbt index f72bdcc0e..6fbb590a4 100644 --- a/version.sbt +++ b/version.sbt @@ -1 +1 @@ -ThisBuild / version := "0.8.4" +ThisBuild / version := "0.8.5" From c25624474e261d73c1eeb12d35b2604ef6e977cf Mon Sep 17 00:00:00 2001 From: Russell Jurney Date: Sat, 15 Feb 2025 21:07:24 -0800 Subject: [PATCH 02/70] Restore Python .gitignore --- python/.gitignore | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 python/.gitignore diff --git a/python/.gitignore b/python/.gitignore new file mode 100644 index 000000000..2130ff922 --- /dev/null +++ b/python/.gitignore @@ -0,0 +1,5 @@ +*.pyc +docs/_build/ +build/ +dist/ + From 6c3df0b1cdf606ddf8e1aed00edd1d93ffb11220 Mon Sep 17 00:00:00 2001 From: Russell Jurney Date: Sat, 15 Feb 2025 21:08:05 -0800 Subject: [PATCH 03/70] Extra newline removed --- python/.gitignore | 1 - 1 file changed, 1 deletion(-) diff --git a/python/.gitignore b/python/.gitignore index 2130ff922..81410ca55 100644 --- a/python/.gitignore +++ b/python/.gitignore @@ -2,4 +2,3 @@ docs/_build/ build/ dist/ - From caf50911ed5da315ca66134798a28ea240b71a81 Mon Sep 17 00:00:00 2001 From: Russell Jurney Date: Sun, 16 Feb 2025 11:58:23 -0800 Subject: [PATCH 04/70] Added VERSION file set to 0.8.5 --- VERSION | 1 + 1 file changed, 1 insertion(+) create mode 100644 VERSION diff --git a/VERSION b/VERSION new file mode 100644 index 000000000..7ada0d303 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.8.5 From 7cfa2d18152e566f39dc57ea5c8e1c6075648542 Mon Sep 17 00:00:00 2001 From: Russell Jurney Date: Sun, 16 Feb 2025 12:40:44 -0800 Subject: [PATCH 05/70] isort; fiex edgesDF variable name. --- python/graphframes/tests.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/python/graphframes/tests.py b/python/graphframes/tests.py index 259435759..d4269f449 100644 --- a/python/graphframes/tests.py +++ b/python/graphframes/tests.py @@ -16,18 +16,18 @@ # import os -import tempfile -import shutil import re +import shutil +import tempfile import pytest from pyspark import SparkConf, SparkContext -from pyspark.sql import functions as F, SparkSession +from pyspark.sql import SparkSession +from pyspark.sql import functions as F -from .graphframe import GraphFrame, Pregel, _java_api, _from_java_gf +from .examples import BeliefPropagation, Graphs +from .graphframe import GraphFrame, Pregel, _from_java_gf, _java_api from .lib import AggregateMessages as AM -from .examples import Graphs, BeliefPropagation - VERSION = open("version.sbt").read().strip() @@ -419,9 +419,9 @@ def test_shortest_paths(self): edges = [(1, 2), (1, 5), (2, 3), (2, 5), (3, 4), (4, 5), (4, 6)] # Create bidirectional edges. all_edges = [z for (a, b) in edges for z in [(a, b), (b, a)]] - edgesDF = self.spark.createDataFrame(all_edges, ["src", "dst"]) + edges = self.spark.createDataFrame(all_edges, ["src", "dst"]) vertices = self.spark.createDataFrame([(i,) for i in range(1, 7)], ["id"]) - g = GraphFrame(vertices, edgesDF) + g = GraphFrame(vertices, edges) landmarks = [1, 4] v2 = g.shortestPaths(landmarks) self._df_hasCols(v2, vcols=["id", "distances"]) From a8bf0be4523bc41dd01966f7b525a9ec7c918ede Mon Sep 17 00:00:00 2001 From: Russell Jurney Date: Sun, 16 Feb 2025 13:08:48 -0800 Subject: [PATCH 06/70] Back out Dockerfile changes --- Dockerfile | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index b9fe8c528..1c4430912 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,16 +1,16 @@ FROM ubuntu:22.04 -ARG PYTHON_VERSION=3.9 +ARG PYTHON_VERSION=3.8 ARG DEBIAN_FRONTEND=noninteractive RUN apt-get update && \ - apt-get install -y wget bzip2 build-essential openjdk-11-jdk ssh sudo && \ + apt-get install -y wget bzip2 build-essential openjdk-8-jdk ssh sudo && \ apt-get clean # Install Spark and update env variables. -ENV SCALA_VERSION 2.12.20 -ENV SPARK_VERSION "3.5.4" -ENV SPARK_BUILD "spark-${SPARK_VERSION}-bin-hadoop3" +ENV SCALA_VERSION 2.12.17 +ENV SPARK_VERSION "3.4.1" +ENV SPARK_BUILD "spark-${SPARK_VERSION}-bin-hadoop3.2" ENV SPARK_BUILD_URL "https://dist.apache.org/repos/dist/release/spark/spark-${SPARK_VERSION}/${SPARK_BUILD}.tgz" RUN wget --quiet "$SPARK_BUILD_URL" -O /tmp/spark.tgz && \ tar -C /opt -xf /tmp/spark.tgz && \ From 54a942da4a572471eead3e5df4f721096d638ca6 Mon Sep 17 00:00:00 2001 From: Russell Jurney Date: Sun, 16 Feb 2025 13:21:47 -0800 Subject: [PATCH 07/70] Back out version change in build.sbt --- build.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.sbt b/build.sbt index c6b503989..4ee4d9bd5 100644 --- a/build.sbt +++ b/build.sbt @@ -3,7 +3,7 @@ import ReleaseTransformations.* lazy val sparkVer = sys.props.getOrElse("spark.version", "3.5.4") lazy val sparkBranch = sparkVer.substring(0, 3) lazy val defaultScalaVer = sparkBranch match { - case "3.5" => "2.12.20" + case "3.5" => "2.12.18" case _ => throw new IllegalArgumentException(s"Unsupported Spark version: $sparkVer.") } lazy val scalaVer = sys.props.getOrElse("scala.version", defaultScalaVer) From 8b0e34697928ad0cb1b07cba96c0d0657a0d771b Mon Sep 17 00:00:00 2001 From: Russell Jurney Date: Sun, 16 Feb 2025 13:23:35 -0800 Subject: [PATCH 08/70] Backout changes to config and run-tests --- docs/_config.yml | 2 +- python/run-tests.sh | 17 ++++++++++++++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/docs/_config.yml b/docs/_config.yml index 379fc242f..4c1ab075c 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -13,7 +13,7 @@ include: # These allow the documentation to be updated with newer releases # of Spark, Scala, and Mesos. -GRAPHFRAMES_VERSION: 0.8.5 +GRAPHFRAMES_VERSION: 0.8.4 #SCALA_BINARY_VERSION: "2.10" #SCALA_VERSION: "2.10.4" #MESOS_VERSION: 0.21.0 diff --git a/python/run-tests.sh b/python/run-tests.sh index dc496e8b0..af4e0a139 100755 --- a/python/run-tests.sh +++ b/python/run-tests.sh @@ -38,7 +38,7 @@ echo $pyver LIBS="" for lib in "$SPARK_HOME/python/lib"/*zip ; do - LIBS=$LIBS:$lib + LIBS=$LIBS:$lib done # The current directory of the script. @@ -51,7 +51,7 @@ assembly_path="$DIR/../target/scala-$scala_version_major_minor" echo `ls $assembly_path/graphframes-assembly*.jar` JAR_PATH="" for assembly in $assembly_path/graphframes-assembly*.jar ; do - JAR_PATH=$assembly + JAR_PATH=$assembly done export PYSPARK_SUBMIT_ARGS="--driver-memory 2g --executor-memory 2g --jars $JAR_PATH pyspark-shell " @@ -62,7 +62,17 @@ export PYTHONPATH=$PYTHONPATH:graphframes # Run test suites -$PYSPARK_DRIVER_PYTHON -m "pytest" -v $DIR/graphframes/tests.py 2>&1 | grep -vE "INFO (ParquetOutputFormat|SparkContext|ContextCleaner|ShuffleBlockFetcherIterator|MapOutputTrackerMaster|TaskSetManager|Executor|MemoryStore|CacheManager|BlockManager|DAGScheduler|PythonRDD|TaskSchedulerImpl|ZippedPartitionsRDD2)"; + +if [[ "$python_major" == "2" ]]; then + + # Horrible hack for spark 1.x: we manually remove some log lines to stay below the 4MB log limit on Travis. + $PYSPARK_DRIVER_PYTHON `which nosetests` -v --all-modules -w $DIR 2>&1 | grep -vE "INFO (ParquetOutputFormat|SparkContext|ContextCleaner|ShuffleBlockFetcherIterator|MapOutputTrackerMaster|TaskSetManager|Executor|MemoryStore|CacheManager|BlockManager|DAGScheduler|PythonRDD|TaskSchedulerImpl|ZippedPartitionsRDD2)"; + +else + + $PYSPARK_DRIVER_PYTHON -m "nose" -v --all-modules -w $DIR 2>&1 | grep -vE "INFO (ParquetOutputFormat|SparkContext|ContextCleaner|ShuffleBlockFetcherIterator|MapOutputTrackerMaster|TaskSetManager|Executor|MemoryStore|CacheManager|BlockManager|DAGScheduler|PythonRDD|TaskSchedulerImpl|ZippedPartitionsRDD2)"; + +fi # Exit immediately if the tests fail. # Since we pipe to remove the output, we need to use some horrible BASH features: @@ -70,6 +80,7 @@ $PYSPARK_DRIVER_PYTHON -m "pytest" -v $DIR/graphframes/tests.py 2>&1 | grep -vE test ${PIPESTATUS[0]} -eq 0 || exit 1; # Run doc tests + cd "$DIR" $PYSPARK_PYTHON -u ./graphframes/graphframe.py "$@" From 46c2b9300ace8c95ba482e6582631b79a348705c Mon Sep 17 00:00:00 2001 From: Russell Jurney Date: Sun, 16 Feb 2025 13:24:19 -0800 Subject: [PATCH 09/70] Back out pytest conversion --- python/graphframes/tests.py | 409 ++++++++++++++++-------------------- 1 file changed, 182 insertions(+), 227 deletions(-) diff --git a/python/graphframes/tests.py b/python/graphframes/tests.py index d4269f449..9a7ad1371 100644 --- a/python/graphframes/tests.py +++ b/python/graphframes/tests.py @@ -15,72 +15,63 @@ # limitations under the License. # -import os -import re -import shutil +import sys import tempfile +import shutil +import re -import pytest -from pyspark import SparkConf, SparkContext -from pyspark.sql import SparkSession -from pyspark.sql import functions as F - -from .examples import BeliefPropagation, Graphs -from .graphframe import GraphFrame, Pregel, _from_java_gf, _java_api -from .lib import AggregateMessages as AM - -VERSION = open("version.sbt").read().strip() - +if sys.version_info[:2] <= (2, 6): + try: + import unittest2 as unittest + except ImportError: + sys.stderr.write('Please install unittest2 to test with Python 2.6 or earlier') + sys.exit(1) +else: + import unittest -@pytest.fixture(scope="class", autouse=True) -def set_spark(request, spark_session): - request.cls.spark = spark_session +from pyspark import SparkContext +from pyspark.sql import functions as sqlfunctions, SparkSession +from .graphframe import GraphFrame, Pregel, _java_api, _from_java_gf +from .lib import AggregateMessages as AM +from .examples import Graphs, BeliefPropagation -@pytest.mark.usefixtures("set_spark") class GraphFrameTestUtils(object): @classmethod def parse_spark_version(cls, version_str): - """take an input version string - return version items in a dictionary + """ take an input version string + return version items in a dictionary """ - _sc_ver_patt = r"(\d+)\.(\d+)(\.(\d+)(-(.+))?)?" + _sc_ver_patt = r'(\d+)\.(\d+)(\.(\d+)(-(.+))?)?' m = re.match(_sc_ver_patt, version_str) if not m: - raise TypeError( - "version {} shoud be in ..".format(version_str) - ) + raise TypeError("version {} shoud be in ..".format(version_str)) version_info = {} try: - version_info["major"] = int(m.group(1)) + version_info['major'] = int(m.group(1)) except: raise TypeError("invalid minor version") try: - version_info["minor"] = int(m.group(2)) + version_info['minor'] = int(m.group(2)) except: raise TypeError("invalid major version") try: - version_info["maintenance"] = int(m.group(4)) + version_info['maintenance'] = int(m.group(4)) except: - version_info["maintenance"] = 0 + version_info['maintenance'] = 0 try: - version_info["special"] = m.group(6) + version_info['special'] = m.group(6) except: pass return version_info @classmethod def createSparkContext(cls): - cls.conf = SparkConf().setAppName("GraphFramesTests") - cls.conf.set( - "spark.submit.pyFiles", - os.path.abspath("python/dist/graphframes-{VERSION}-py3-none-any.whl"), - ) - cls.sc = SparkContext(master="local[4]", appName="GraphFramesTests", conf=cls.conf) + cls.sc = sc = SparkContext('local[4]', "GraphFramesTests") cls.checkpointDir = tempfile.mkdtemp() cls.sc.setCheckpointDir(cls.checkpointDir) - cls.spark_version = cls.parse_spark_version(cls.sc.version) + cls.spark_version = cls.parse_spark_version(sc.version) @classmethod def stopSparkContext(cls): @@ -90,10 +81,10 @@ def stopSparkContext(cls): @classmethod def spark_at_least_of_version(cls, version_str): - assert hasattr(cls, "spark_version") + assert hasattr(cls, 'spark_version') required_version = cls.parse_spark_version(version_str) spark_version = cls.spark_version - for _name in ["major", "minor", "maintenance"]: + for _name in ['major', 'minor', 'maintenance']: sc_ver = spark_version[_name] req_ver = required_version[_name] if sc_ver != req_ver: @@ -101,31 +92,28 @@ def spark_at_least_of_version(cls, version_str): # All major.minor.maintenance equal return True - -@pytest.fixture(scope="module", autouse=True) -def spark_context(): +def setUpModule(): GraphFrameTestUtils.createSparkContext() - yield + +def tearDownModule(): GraphFrameTestUtils.stopSparkContext() -@pytest.fixture(scope="class") -def spark_session(): - # Create a SparkSession with a smaller number of shuffle partitions. - spark = ( - SparkSession(GraphFrameTestUtils.sc) - .builder.config("spark.sql.shuffle.partitions", 4) - .getOrCreate() - ) - yield spark - # No explicit stop; SparkContext shutdown will clean up. +class GraphFrameTestCase(unittest.TestCase): + + @classmethod + def setUpClass(cls): + # Small tests run much faster with spark.sql.shuffle.partitions = 4 + cls.spark = SparkSession(GraphFrameTestUtils.sc).builder.config('spark.sql.shuffle.partitions', 4).getOrCreate() + @classmethod + def tearDownClass(cls): + cls.spark = None -@pytest.mark.usefixtures("set_spark") -class GraphFrameTest: - def setup_method(self, method): - # Mimic setUp: create a simple GraphFrame instance for each test. +class GraphFrameTest(GraphFrameTestCase): + def setUp(self): + super(GraphFrameTest, self).setUp() localVertices = [(1, "A"), (2, "B"), (3, "C")] localEdges = [(1, 2, "love"), (2, 1, "hate"), (2, 3, "follow")] v = self.spark.createDataFrame(localVertices, ["id", "name"]) @@ -135,38 +123,28 @@ def setup_method(self, method): def test_spark_version_check(self): gtu = GraphFrameTestUtils gtu.spark_version = gtu.parse_spark_version("2.0.2") - - assert gtu.spark_at_least_of_version("1.7") - assert gtu.spark_at_least_of_version("2.0") - assert gtu.spark_at_least_of_version("2.0.1") - assert gtu.spark_at_least_of_version("2.0.2") - assert not gtu.spark_at_least_of_version("2.0.3") - assert not gtu.spark_at_least_of_version("2.1") + self.assertTrue(gtu.spark_at_least_of_version("1.7")) + self.assertTrue(gtu.spark_at_least_of_version("2.0")) + self.assertTrue(gtu.spark_at_least_of_version("2.0.1")) + self.assertTrue(gtu.spark_at_least_of_version("2.0.2")) + self.assertFalse(gtu.spark_at_least_of_version("2.0.3")) + self.assertFalse(gtu.spark_at_least_of_version("2.1")) def test_construction(self): g = self.g - vertexIDs = [row[0] for row in g.vertices.select("id").collect()] + vertexIDs = map(lambda x: x[0], g.vertices.select("id").collect()) assert sorted(vertexIDs) == [1, 2, 3] - - edgeActions = [row[0] for row in g.edges.select("action").collect()] + edgeActions = map(lambda x: x[0], g.edges.select("action").collect()) assert sorted(edgeActions) == ["follow", "hate", "love"] - - tripletsFirst = list( - map( - lambda x: (x[0][1], x[1][1], x[2][2]), - g.triplets.sort("src.id").select("src", "dst", "edge").take(1), - ) - ) + tripletsFirst = list(map(lambda x: (x[0][1], x[1][1], x[2][2]), + g.triplets.sort("src.id").select("src", "dst", "edge").take(1))) assert tripletsFirst == [("A", "B", "love")], tripletsFirst - # Try with invalid vertices and edges DataFrames v_invalid = self.spark.createDataFrame( - [(1, "A"), (2, "B"), (3, "C")], ["invalid_colname_1", "invalid_colname_2"] - ) + [(1, "A"), (2, "B"), (3, "C")], ["invalid_colname_1", "invalid_colname_2"]) e_invalid = self.spark.createDataFrame( - [(1, 2), (2, 3), (3, 1)], ["invalid_colname_3", "invalid_colname_4"] - ) - with pytest.raises(ValueError): + [(1, 2), (2, 3), (3, 1)], ["invalid_colname_3", "invalid_colname_4"]) + with self.assertRaises(ValueError): GraphFrame(v_invalid, e_invalid) def test_cache(self): @@ -177,17 +155,17 @@ def test_cache(self): def test_degrees(self): g = self.g outDeg = g.outDegrees - assert set(outDeg.columns) == {"id", "outDegree"} + self.assertSetEqual(set(outDeg.columns), {"id", "outDegree"}) inDeg = g.inDegrees - assert set(inDeg.columns) == {"id", "inDegree"} + self.assertSetEqual(set(inDeg.columns), {"id", "inDegree"}) deg = g.degrees - assert set(deg.columns) == {"id", "degree"} + self.assertSetEqual(set(deg.columns), {"id", "degree"}) def test_motif_finding(self): g = self.g motifs = g.find("(a)-[e]->(b)") assert motifs.count() == 3 - assert set(motifs.columns) == {"a", "e", "b"} + self.assertSetEqual(set(motifs.columns), {"a", "e", "b"}) def test_filterVertices(self): g = self.g @@ -200,8 +178,8 @@ def test_filterVertices(self): e2 = g2.edges.select("src", "dst", "action").collect() assert len(v2) == len(expected_v) assert len(e2) == len(expected_e) - assert set(v2) == set(expected_v) - assert set(e2) == set(expected_e) + self.assertSetEqual(set(v2), set(expected_v)) + self.assertSetEqual(set(e2), set(expected_e)) def test_filterEdges(self): g = self.g @@ -214,8 +192,8 @@ def test_filterEdges(self): e2 = g2.edges.select("src", "dst", "action").collect() assert len(v2) == len(expected_v) assert len(e2) == len(expected_e) - assert set(v2) == set(expected_v) - assert set(e2) == set(expected_e) + self.assertSetEqual(set(v2), set(expected_v)) + self.assertSetEqual(set(e2), set(expected_e)) def test_dropIsolatedVertices(self): g = self.g @@ -226,93 +204,74 @@ def test_dropIsolatedVertices(self): expected_e = [(2, 3, "follow")] assert len(v2) == len(expected_v) assert len(e2) == len(expected_e) - assert set(v2) == set(expected_v) - assert set(e2) == set(expected_e) + self.assertSetEqual(set(v2), set(expected_v)) + self.assertSetEqual(set(e2), set(expected_e)) def test_bfs(self): g = self.g paths = g.bfs("name='A'", "name='C'") - assert paths.count() == 1 - # Expecting that the first intermediary vertex in the BFS is "B" - assert paths.select("v1.name").head()[0] == "B" - + self.assertEqual(paths.count(), 1) + self.assertEqual(paths.select("v1.name").head()[0], "B") paths2 = g.bfs("name='A'", "name='C'", edgeFilter="action!='follow'") - assert paths2.count() == 0 - + self.assertEqual(paths2.count(), 0) paths3 = g.bfs("name='A'", "name='C'", maxPathLength=1) - assert paths3.count() == 0 + self.assertEqual(paths3.count(), 0) -@pytest.mark.usefixtures("set_spark") -class TestPregel: +class PregelTest(GraphFrameTestCase): + def setUp(self): + super(PregelTest, self).setUp() def test_page_rank(self): - # Create an edge DataFrame; note that vertex 3 has no in-links. - edges = self.spark.createDataFrame( - [[0, 1], [1, 2], [2, 4], [2, 0], [3, 4], [4, 0], [4, 2]], - ["src", "dst"], - ) + from pyspark.sql.functions import coalesce, col, lit, sum, when + edges = self.spark.createDataFrame([[0, 1], + [1, 2], + [2, 4], + [2, 0], + [3, 4], # 3 has no in-links + [4, 0], + [4, 2]], ["src", "dst"]) edges.cache() - - # Create a vertex DataFrame and count vertices. vertices = self.spark.createDataFrame([[0], [1], [2], [3], [4]], ["id"]) numVertices = vertices.count() - - # Get the outDegrees DataFrame from a GraphFrame built on the original vertices and edges. vertices = GraphFrame(vertices, edges).outDegrees vertices.cache() - - # Construct a new GraphFrame with the updated vertices DataFrame. graph = GraphFrame(vertices, edges) alpha = 0.15 - - # Run PageRank via Pregel. - ranks = ( - graph.pregel.setMaxIter(5) - .withVertexColumn( - "rank", - F.lit(1.0 / numVertices), - F.coalesce(Pregel.msg(), F.lit(0.0)) * F.lit(1.0 - alpha) - + F.lit(alpha / numVertices), - ) - .sendMsgToDst(Pregel.src("rank") / Pregel.src("outDegree")) - .aggMsgs(F.sum(Pregel.msg())) + ranks = graph.pregel \ + .setMaxIter(5) \ + .withVertexColumn("rank", lit(1.0 / numVertices), + coalesce(Pregel.msg(), + lit(0.0)) * lit(1.0 - alpha) + lit(alpha / numVertices)) \ + .sendMsgToDst(Pregel.src("rank") / Pregel.src("outDegree")) \ + .aggMsgs(sum(Pregel.msg())) \ .run() - ) - - # Collect and sort results. resultRows = ranks.sort(ranks.id).collect() - result = list(map(lambda x: x.rank, resultRows)) + result = map(lambda x: x.rank, resultRows) expected = [0.245, 0.224, 0.303, 0.03, 0.197] - - # Compare each result with its expected value using a tolerance of 1e-3. for a, b in zip(result, expected): - assert a == pytest.approx(b, abs=1e-3) - + self.assertAlmostEqual(a, b, delta = 1e-3) -@pytest.mark.usefixtures("set_spark") -class TestGraphFrameLib: - def setup_method(self, method): - # Set up the Java API instance for each test. +class GraphFrameLibTest(GraphFrameTestCase): + def setUp(self): + super(GraphFrameLibTest, self).setUp() self.japi = _java_api(self.spark._sc) - def _hasCols(self, graph, vcols=[], ecols=[]): - for c in vcols: - assert c in graph.vertices.columns, f"Vertex DataFrame missing column: {c}" - for c in ecols: - assert c in graph.edges.columns, f"Edge DataFrame missing column: {c}" + def _hasCols(self, graph, vcols = [], ecols = []): + map(lambda c: self.assertIn(c, graph.vertices.columns), vcols) + map(lambda c: self.assertIn(c, graph.edges.columns), ecols) - def _df_hasCols(self, df, vcols=[]): - for c in vcols: - assert c in df.columns, f"DataFrame missing column: {c}" + def _df_hasCols(self, vertices, vcols = []): + map(lambda c: self.assertIn(c, vertices.columns), vcols) def _graph(self, name, *args): """ - Convenience to call one of the example graphs, passing the arguments and wrapping the result as a Python object. - :param name: the name of the example graph. - :param args: all the required arguments (excluding the initial SparkSession). - :return: a GraphFrame object. + Convenience to call one of the example graphs, passing the arguments and wrapping the result back + as a python object. + :param name: the name of the example graph + :param args: all the required arguments, without the initial spark session + :return: """ examples = self.japi.examples() jgraph = getattr(examples, name)(*args) @@ -322,79 +281,83 @@ def test_aggregate_messages(self): g = self._graph("friends") # For each user, sum the ages of the adjacent users, # plus 1 for the src's sum if the edge is "friend". - sendToSrc = AM.dst["age"] + F.when(AM.edge["relationship"] == "friend", F.lit(1)).otherwise( - 0 - ) - sendToDst = AM.src["age"] + sendToSrc = ( + AM.dst['age'] + + sqlfunctions.when( + AM.edge['relationship'] == 'friend', + sqlfunctions.lit(1) + ).otherwise(0)) + sendToDst = AM.src['age'] agg = g.aggregateMessages( - F.sum(AM.msg).alias("summedAges"), sendToSrc=sendToSrc, sendToDst=sendToDst - ) - # Run the aggregation again using SQL expressions as Strings. + sqlfunctions.sum(AM.msg).alias('summedAges'), + sendToSrc=sendToSrc, + sendToDst=sendToDst) + # Run the aggregation again providing SQL expressions as String instead. agg2 = g.aggregateMessages( "sum(MSG) AS `summedAges`", sendToSrc="(dst['age'] + CASE WHEN (edge['relationship'] = 'friend') THEN 1 ELSE 0 END)", - sendToDst="src['age']", - ) - # Build mappings from id to the aggregated message. - aggMap = {row.id: row.summedAges for row in agg.select("id", "summedAges").collect()} - agg2Map = {row.id: row.summedAges for row in agg2.select("id", "summedAges").collect()} - # Compute the expected aggregation via brute force. - user2age = {row.id: row.age for row in g.vertices.select("id", "age").collect()} + sendToDst="src['age']") + # Convert agg and agg2 to a mapping from id to the aggregated message. + aggMap = {id_: s for id_, s in agg.select('id', 'summedAges').collect()} + agg2Map = {id_: s for id_, s in agg2.select('id', 'summedAges').collect()} + # Compute the truth via brute force. + user2age = {id_: age for id_, age in g.vertices.select('id', 'age').collect()} trueAgg = {} - for row in g.edges.select("src", "dst", "relationship").collect(): - src, dst, rel = row.src, row.dst, row.relationship - trueAgg[src] = trueAgg.get(src, 0) + user2age[dst] + (1 if rel == "friend" else 0) + for src, dst, rel in g.edges.select("src", "dst", "relationship").collect(): + trueAgg[src] = trueAgg.get(src, 0) + user2age[dst] + (1 if rel == 'friend' else 0) trueAgg[dst] = trueAgg.get(dst, 0) + user2age[src] - # Verify both aggregations match the expected results. - assert aggMap == trueAgg, f"aggMap {aggMap} does not equal expected {trueAgg}" - assert agg2Map == trueAgg, f"agg2Map {agg2Map} does not equal expected {trueAgg}" - # Check that passing a wrong type for messages raises a TypeError. - with pytest.raises(TypeError): + # Compare if the agg mappings match the brute force mapping + self.assertEqual(aggMap, trueAgg) + self.assertEqual(agg2Map, trueAgg) + # Check that TypeError is raises with messages of wrong type + with self.assertRaises(TypeError): g.aggregateMessages( - "sum(MSG) AS `summedAges`", sendToSrc=object(), sendToDst="src['age']" - ) - with pytest.raises(TypeError): + "sum(MSG) AS `summedAges`", + sendToSrc=object(), + sendToDst="src['age']") + with self.assertRaises(TypeError): g.aggregateMessages( - "sum(MSG) AS `summedAges`", sendToSrc=F.col("dst")["age"], sendToDst=object() - ) + "sum(MSG) AS `summedAges`", + sendToSrc=dst['age'], + sendToDst=object()) def test_connected_components(self): - v = self.spark.createDataFrame([(0, "a", "b")], ["id", "vattr", "gender"]) + v = self.spark.createDataFrame([ + (0, "a", "b")], ["id", "vattr", "gender"]) e = self.spark.createDataFrame([(0, 0, 1)], ["src", "dst", "test"]).filter("src > 10") g = GraphFrame(v, e) comps = g.connectedComponents() - self._df_hasCols(comps, vcols=["id", "component", "vattr", "gender"]) - assert comps.count() == 1 + self._df_hasCols(comps, vcols=['id', 'component', 'vattr', 'gender']) + self.assertEqual(comps.count(), 1) def test_connected_components2(self): v = self.spark.createDataFrame([(0, "a0", "b0"), (1, "a1", "b1")], ["id", "A", "B"]) e = self.spark.createDataFrame([(0, 1, "a01", "b01")], ["src", "dst", "A", "B"]) g = GraphFrame(v, e) comps = g.connectedComponents() - self._df_hasCols(comps, vcols=["id", "component", "A", "B"]) - assert comps.count() == 2 + self._df_hasCols(comps, vcols=['id', 'component', 'A', 'B']) + self.assertEqual(comps.count(), 2) def test_connected_components_friends(self): g = self._graph("friends") - comps_tests = [ - g.connectedComponents(), - g.connectedComponents(broadcastThreshold=1), - g.connectedComponents(checkpointInterval=0), - g.connectedComponents(checkpointInterval=10), - g.connectedComponents(algorithm="graphx"), - ] + comps_tests = [] + comps_tests += [g.connectedComponents()] + comps_tests += [g.connectedComponents(broadcastThreshold=1)] + comps_tests += [g.connectedComponents(checkpointInterval=0)] + comps_tests += [g.connectedComponents(checkpointInterval=10)] + comps_tests += [g.connectedComponents(algorithm="graphx")] for c in comps_tests: - assert c.groupBy("component").count().count() == 2 + self.assertEqual(c.groupBy("component").count().count(), 2) def test_label_progagation(self): n = 5 g = self._graph("twoBlobs", n) labels = g.labelPropagation(maxIter=4 * n) labels1 = labels.filter("id < 5").select("label").collect() - all1 = {row.label for row in labels1} + all1 = set([x.label for x in labels1]) assert len(all1) == 1 labels2 = labels.filter("id >= 5").select("label").collect() - all2 = {row.label for row in labels2} + all2 = set([x.label for x in labels2]) assert len(all2) == 1 assert all1 != all2 @@ -404,7 +367,7 @@ def test_page_rank(self): resetProb = 0.15 errorTol = 1.0e-5 pr = g.pageRank(resetProb, tol=errorTol) - self._hasCols(pr, vcols=["id", "pagerank"], ecols=["src", "dst", "weight"]) + self._hasCols(pr, vcols=['id', 'pagerank'], ecols=['src', 'dst', 'weight']) def test_parallel_personalized_page_rank(self): n = 100 @@ -413,11 +376,10 @@ def test_parallel_personalized_page_rank(self): maxIter = 15 sourceIds = [1, 2, 3, 4] pr = g.parallelPersonalizedPageRank(resetProb, sourceIds=sourceIds, maxIter=maxIter) - self._hasCols(pr, vcols=["id", "pageranks"], ecols=["src", "dst", "weight"]) + self._hasCols(pr, vcols=['id', 'pageranks'], ecols=['src', 'dst', 'weight']) def test_shortest_paths(self): edges = [(1, 2), (1, 5), (2, 3), (2, 5), (3, 4), (4, 5), (4, 6)] - # Create bidirectional edges. all_edges = [z for (a, b) in edges for z in [(a, b), (b, a)]] edges = self.spark.createDataFrame(all_edges, ["src", "dst"]) vertices = self.spark.createDataFrame([(i,) for i in range(1, 7)], ["id"]) @@ -428,19 +390,17 @@ def test_shortest_paths(self): def test_svd_plus_plus(self): g = self._graph("ALSSyntheticData") - v2, cost = g.svdPlusPlus() - self._df_hasCols(v2, vcols=["id", "column1", "column2", "column3", "column4"]) + (v2, cost) = g.svdPlusPlus() + self._df_hasCols(v2, vcols=['id', 'column1', 'column2', 'column3', 'column4']) def test_strongly_connected_components(self): - # Simple island test. + # Simple island test vertices = self.spark.createDataFrame([(i,) for i in range(1, 6)], ["id"]) edges = self.spark.createDataFrame([(7, 8)], ["src", "dst"]) g = GraphFrame(vertices, edges) c = g.stronglyConnectedComponents(5) for row in c.collect(): - assert ( - row.id == row.component - ), f"Vertex {row.id} not equal to its component {row.component}" + self.assertEqual(row.id, row.component) def test_triangle_counts(self): edges = self.spark.createDataFrame([(0, 1), (1, 2), (2, 0)], ["src", "dst"]) @@ -448,66 +408,61 @@ def test_triangle_counts(self): g = GraphFrame(vertices, edges) c = g.triangleCount() for row in c.select("id", "count").collect(): - assert row.asDict()["count"] == 1, f"Triangle count for vertex {row.id} is not 1" - + self.assertEqual(row.asDict()['count'], 1) + def test_mutithreaded_sparksession_usage(self): - # Test that the GraphFrame API works correctly from multiple threads. + # Test that we can use the GraphFrame API from multiple threads localVertices = [(1, "A"), (2, "B"), (3, "C")] localEdges = [(1, 2, "love"), (2, 1, "hate"), (2, 3, "follow")] v = self.spark.createDataFrame(localVertices, ["id", "name"]) e = self.spark.createDataFrame(localEdges, ["src", "dst", "action"]) - + + exc = None - def run_graphframe() -> None: - nonlocal exc try: GraphFrame(v, e) except Exception as _e: + nonlocal exc exc = _e - + import threading - thread = threading.Thread(target=run_graphframe) thread.start() thread.join() - assert exc is None, f"Exception was raised in thread: {exc}" - + self.assertIsNone(exc, f"Exception was raised in thread: {exc}") -@pytest.mark.usefixtures("set_spark") -class TestGraphFrameExamples: - def setup_method(self, method): - # Set up the Java API instance for use in the tests. +class GraphFrameExamplesTest(GraphFrameTestCase): + def setUp(self): + super(GraphFrameExamplesTest, self).setUp() self.japi = _java_api(self.spark._sc) def test_belief_propagation(self): - # Create a graphical model g of size 3x3. + # create graphical model g of size 3 x 3 g = Graphs(self.spark).gridIsingModel(3) - # Run Belief Propagation (BP) for 5 iterations. + # run BP for 5 iterations numIter = 5 results = BeliefPropagation.runBPwithGraphFrames(g, numIter) - # Check that each belief is a valid probability in [0, 1]. - for row in results.vertices.select("belief").collect(): - belief = row["belief"] - assert ( - 0 <= belief <= 1 - ), f"Expected belief to be probability in [0,1], but found {belief}" + # check beliefs are valid + for row in results.vertices.select('belief').collect(): + belief = row['belief'] + self.assertTrue( + 0 <= belief <= 1, + msg="Expected belief to be probability in [0,1], but found {}".format(belief)) def test_graph_friends(self): - # Construct the graph. + # construct graph g = Graphs(self.spark).friends() - # Check that the result is an instance of GraphFrame. - assert isinstance(g, GraphFrame) + # check that a GraphFrame instance was returned + self.assertIsInstance(g, GraphFrame) def test_graph_grid_ising_model(self): - # Construct a grid Ising model graph. + # construct graph n = 3 g = Graphs(self.spark).gridIsingModel(n) - # Collect the vertex ids. - ids = [v["id"] for v in g.vertices.collect()] - # Verify that every expected vertex id appears. + # check that all the vertices exist + ids = [v['id'] for v in g.vertices.collect()] for i in range(n): for j in range(n): - expected_id = f"{i},{j}" - assert expected_id in ids, f"Vertex {expected_id} not found in {ids}" + self.assertIn('{},{}'.format(i, j), ids) From 18b5da033042e328c08062c03002fba1c7ab7a75 Mon Sep 17 00:00:00 2001 From: Russell Jurney Date: Sun, 16 Feb 2025 13:27:42 -0800 Subject: [PATCH 10/70] Back out version changes to make nose tests pass --- .github/workflows/python-ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml index 36b6b97e7..157b328f1 100644 --- a/.github/workflows/python-ci.yml +++ b/.github/workflows/python-ci.yml @@ -7,8 +7,8 @@ jobs: matrix: include: - spark-version: 3.5.4 - scala-version: 2.12.20 - python-version: 3.11.11 + scala-version: 2.12.18 + python-version: 3.9.19 runs-on: ubuntu-22.04 env: # define Java options for both official sbt and sbt-extras From 8eca097f11c75c82dd72b2d5de596c935192f400 Mon Sep 17 00:00:00 2001 From: Russell Jurney Date: Sun, 16 Feb 2025 13:30:24 -0800 Subject: [PATCH 11/70] Remove changes to requirements --- python/requirements.txt | 2 -- 1 file changed, 2 deletions(-) diff --git a/python/requirements.txt b/python/requirements.txt index fb73319f2..3db67f231 100644 --- a/python/requirements.txt +++ b/python/requirements.txt @@ -1,6 +1,4 @@ # This file should list any python package dependencies. pyspark>=2.0.0 -click==8.1.8 numpy>=1.7 py7zr==0.22.0 -requests==2.32.3 From 277c06fe75fe288657f64778e78d9b0a9712a2ae Mon Sep 17 00:00:00 2001 From: Russell Jurney Date: Sun, 16 Feb 2025 13:31:16 -0800 Subject: [PATCH 12/70] Put nose back in requirements.txt --- python/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/requirements.txt b/python/requirements.txt index 3db67f231..9893b3cb1 100644 --- a/python/requirements.txt +++ b/python/requirements.txt @@ -1,4 +1,4 @@ # This file should list any python package dependencies. +nose==1.3.7 pyspark>=2.0.0 numpy>=1.7 -py7zr==0.22.0 From b55ee4881849a882717bc0035902a855817c7113 Mon Sep 17 00:00:00 2001 From: Russell Jurney Date: Sun, 16 Feb 2025 13:31:51 -0800 Subject: [PATCH 13/70] Remove version bump to version.sbt --- version.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.sbt b/version.sbt index 6fbb590a4..f72bdcc0e 100644 --- a/version.sbt +++ b/version.sbt @@ -1 +1 @@ -ThisBuild / version := "0.8.5" +ThisBuild / version := "0.8.4" From f8a8fd9ea062ad2e0504ddb7e7e22b70d5c7b013 Mon Sep 17 00:00:00 2001 From: Russell Jurney Date: Sun, 16 Feb 2025 13:39:29 -0800 Subject: [PATCH 14/70] Remove packages related to testing --- python/requirements-dev.txt | 2 -- 1 file changed, 2 deletions(-) diff --git a/python/requirements-dev.txt b/python/requirements-dev.txt index b27da4d73..6e596dc62 100644 --- a/python/requirements-dev.txt +++ b/python/requirements-dev.txt @@ -1,5 +1,3 @@ -pytest==8.3.4 -Sphinx==8.1.3 flake8==7.1.1 isort==6.0.0 mypy==1.14.1 From bc2cb36e7f1012c10b12907ff3b6b284e9c9b6c3 Mon Sep 17 00:00:00 2001 From: Russell Jurney Date: Sun, 16 Feb 2025 14:15:36 -0800 Subject: [PATCH 15/70] Remove old setup.py / setup.cfg --- python/setup.cfg | 42 ------------------------------------------ python/setup.py | 35 ----------------------------------- 2 files changed, 77 deletions(-) delete mode 100644 python/setup.cfg delete mode 100644 python/setup.py diff --git a/python/setup.cfg b/python/setup.cfg deleted file mode 100644 index 02a0d5136..000000000 --- a/python/setup.cfg +++ /dev/null @@ -1,42 +0,0 @@ -[metadata] -name = graphframes -version = 0.8.5 -description = GraphFrames: Graph Processing Framework for Apache Spark -long_description = file: ../README.md -long_description_content_type = text/markdown -author = GraphFrames Contributors -author_email = graphframes@googlegroups.com -url = https://pypi.org/project/graphframes-py/ -license = Apache License 2.0 -classifiers = - Development Status :: 4 - Beta - Programming Language :: Python :: 3 - Operating System :: OS Independent - -[options] -packages = find: -package_dir = - = python -include_package_data = True -install_requires = - pyspark>=2.0.0 - click==8.1.8 - numpy>=1.7 - py7zr==0.22.0 - requests==2.32.3 - -[options.packages.find] -where = python - exclude = - tests.py - docs - -[options.extras_require] -dev = - pytest==8.3.4 - Sphinx==8.1.3 - black==25.1.0 - flake8==7.1.1 - isort==6.0.0 - mypy==1.14.1 - pre-commit==3.5.1 diff --git a/python/setup.py b/python/setup.py deleted file mode 100644 index a91fb629a..000000000 --- a/python/setup.py +++ /dev/null @@ -1,35 +0,0 @@ -from setuptools import setup, find_packages # type: ignore -import os - - -def parse_requirements(filename): - """Load requirements from a pip requirements file.""" - with open(filename, encoding="utf-8") as f: - # Filter out comments and empty lines. - return [line.strip() for line in f if line.strip() and not line.startswith("#")] - - -# Read the long description from the README file. -here = os.path.abspath(os.path.dirname(__file__)) - -# Use requirements.txt to get the list of dependencies. -requirements = parse_requirements(os.path.join(here, "requirements.txt")) - -setup( - name="graphframes", - version=open("version.sbt").read().strip(), # Update this version as needed - description="GraphFrames: Graph Processing Framework for Apache Spark", - long_description=open(os.path.join(f"{here}/..", "README.md"), encoding="utf-8").read(), - long_description_content_type="text/markdown", - author="GraphFrames Contributors", - author_email="graphframes@googlegroups.com", - url="https://pypi.org/project/graphframes-py", - packages=find_packages(where="python"), - package_dir={"": "python"}, - include_package_data=True, # Include non-code files specified in MANIFEST.in - install_requires=requirements, - classifiers=[ - "Programming Language :: Python :: 3", - "Operating System :: OS Independent", - ], -) From 728be33b6dfd2ee7135711df752289966afdbc2a Mon Sep 17 00:00:00 2001 From: Russell Jurney Date: Sun, 16 Feb 2025 14:15:46 -0800 Subject: [PATCH 16/70] New pyproject.toml and poetry.lock --- python/poetry.lock | 360 ++++++++++++++++++++++++++++++++++++++++++ python/pyproject.toml | 38 +++++ 2 files changed, 398 insertions(+) create mode 100644 python/poetry.lock create mode 100644 python/pyproject.toml diff --git a/python/poetry.lock b/python/poetry.lock new file mode 100644 index 000000000..6eb61618d --- /dev/null +++ b/python/poetry.lock @@ -0,0 +1,360 @@ +# This file is automatically @generated by Poetry 2.0.1 and should not be changed by hand. + +[[package]] +name = "black" +version = "25.1.0" +description = "The uncompromising code formatter." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "black-25.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:759e7ec1e050a15f89b770cefbf91ebee8917aac5c20483bc2d80a6c3a04df32"}, + {file = "black-25.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e519ecf93120f34243e6b0054db49c00a35f84f195d5bce7e9f5cfc578fc2da"}, + {file = "black-25.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:055e59b198df7ac0b7efca5ad7ff2516bca343276c466be72eb04a3bcc1f82d7"}, + {file = "black-25.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:db8ea9917d6f8fc62abd90d944920d95e73c83a5ee3383493e35d271aca872e9"}, + {file = "black-25.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a39337598244de4bae26475f77dda852ea00a93bd4c728e09eacd827ec929df0"}, + {file = "black-25.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:96c1c7cd856bba8e20094e36e0f948718dc688dba4a9d78c3adde52b9e6c2299"}, + {file = "black-25.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bce2e264d59c91e52d8000d507eb20a9aca4a778731a08cfff7e5ac4a4bb7096"}, + {file = "black-25.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:172b1dbff09f86ce6f4eb8edf9dede08b1fce58ba194c87d7a4f1a5aa2f5b3c2"}, + {file = "black-25.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4b60580e829091e6f9238c848ea6750efed72140b91b048770b64e74fe04908b"}, + {file = "black-25.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e2978f6df243b155ef5fa7e558a43037c3079093ed5d10fd84c43900f2d8ecc"}, + {file = "black-25.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b48735872ec535027d979e8dcb20bf4f70b5ac75a8ea99f127c106a7d7aba9f"}, + {file = "black-25.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:ea0213189960bda9cf99be5b8c8ce66bb054af5e9e861249cd23471bd7b0b3ba"}, + {file = "black-25.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8f0b18a02996a836cc9c9c78e5babec10930862827b1b724ddfe98ccf2f2fe4f"}, + {file = "black-25.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:afebb7098bfbc70037a053b91ae8437c3857482d3a690fefc03e9ff7aa9a5fd3"}, + {file = "black-25.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:030b9759066a4ee5e5aca28c3c77f9c64789cdd4de8ac1df642c40b708be6171"}, + {file = "black-25.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:a22f402b410566e2d1c950708c77ebf5ebd5d0d88a6a2e87c86d9fb48afa0d18"}, + {file = "black-25.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a1ee0a0c330f7b5130ce0caed9936a904793576ef4d2b98c40835d6a65afa6a0"}, + {file = "black-25.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f3df5f1bf91d36002b0a75389ca8663510cf0531cca8aa5c1ef695b46d98655f"}, + {file = "black-25.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9e6827d563a2c820772b32ce8a42828dc6790f095f441beef18f96aa6f8294e"}, + {file = "black-25.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:bacabb307dca5ebaf9c118d2d2f6903da0d62c9faa82bd21a33eecc319559355"}, + {file = "black-25.1.0-py3-none-any.whl", hash = "sha256:95e8176dae143ba9097f351d174fdaf0ccd29efb414b362ae3fd72bf0f710717"}, + {file = "black-25.1.0.tar.gz", hash = "sha256:33496d5cd1222ad73391352b4ae8da15253c5de89b93a80b3e2c8d9a19ec2666"}, +] + +[package.dependencies] +click = ">=8.0.0" +mypy-extensions = ">=0.4.3" +packaging = ">=22.0" +pathspec = ">=0.9.0" +platformdirs = ">=2" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +typing-extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""} + +[package.extras] +colorama = ["colorama (>=0.4.3)"] +d = ["aiohttp (>=3.10)"] +jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] +uvloop = ["uvloop (>=0.15.2)"] + +[[package]] +name = "click" +version = "8.1.8" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2"}, + {file = "click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +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 = ["dev"] +markers = "platform_system == \"Windows\"" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "flake8" +version = "7.1.2" +description = "the modular source code checker: pep8 pyflakes and co" +optional = false +python-versions = ">=3.8.1" +groups = ["dev"] +files = [ + {file = "flake8-7.1.2-py2.py3-none-any.whl", hash = "sha256:1cbc62e65536f65e6d754dfe6f1bada7f5cf392d6f5db3c2b85892466c3e7c1a"}, + {file = "flake8-7.1.2.tar.gz", hash = "sha256:c586ffd0b41540951ae41af572e6790dbd49fc12b3aa2541685d253d9bd504bd"}, +] + +[package.dependencies] +mccabe = ">=0.7.0,<0.8.0" +pycodestyle = ">=2.12.0,<2.13.0" +pyflakes = ">=3.2.0,<3.3.0" + +[[package]] +name = "isort" +version = "6.0.0" +description = "A Python utility / library to sort Python imports." +optional = false +python-versions = ">=3.9.0" +groups = ["dev"] +files = [ + {file = "isort-6.0.0-py3-none-any.whl", hash = "sha256:567954102bb47bb12e0fae62606570faacddd441e45683968c8d1734fb1af892"}, + {file = "isort-6.0.0.tar.gz", hash = "sha256:75d9d8a1438a9432a7d7b54f2d3b45cad9a4a0fdba43617d9873379704a8bdf1"}, +] + +[package.extras] +colors = ["colorama"] +plugins = ["setuptools"] + +[[package]] +name = "mccabe" +version = "0.7.0" +description = "McCabe checker, plugin for flake8" +optional = false +python-versions = ">=3.6" +groups = ["dev"] +files = [ + {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, + {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, +] + +[[package]] +name = "mypy-extensions" +version = "1.0.0" +description = "Type system extensions for programs checked with the mypy type checker." +optional = false +python-versions = ">=3.5" +groups = ["dev"] +files = [ + {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, + {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, +] + +[[package]] +name = "nose" +version = "1.3.7" +description = "nose extends unittest to make testing easier" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "nose-1.3.7-py2-none-any.whl", hash = "sha256:dadcddc0aefbf99eea214e0f1232b94f2fa9bd98fa8353711dacb112bfcbbb2a"}, + {file = "nose-1.3.7-py3-none-any.whl", hash = "sha256:9ff7c6cc443f8c51994b34a667bbcf45afd6d945be7477b52e97516fd17c53ac"}, + {file = "nose-1.3.7.tar.gz", hash = "sha256:f1bffef9cbc82628f6e7d7b40d7e255aefaa1adb6a1b1d26c69a8b79e6208a98"}, +] + +[[package]] +name = "numpy" +version = "2.0.2" +description = "Fundamental package for array computing in Python" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "numpy-2.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:51129a29dbe56f9ca83438b706e2e69a39892b5eda6cedcb6b0c9fdc9b0d3ece"}, + {file = "numpy-2.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f15975dfec0cf2239224d80e32c3170b1d168335eaedee69da84fbe9f1f9cd04"}, + {file = "numpy-2.0.2-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:8c5713284ce4e282544c68d1c3b2c7161d38c256d2eefc93c1d683cf47683e66"}, + {file = "numpy-2.0.2-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:becfae3ddd30736fe1889a37f1f580e245ba79a5855bff5f2a29cb3ccc22dd7b"}, + {file = "numpy-2.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2da5960c3cf0df7eafefd806d4e612c5e19358de82cb3c343631188991566ccd"}, + {file = "numpy-2.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:496f71341824ed9f3d2fd36cf3ac57ae2e0165c143b55c3a035ee219413f3318"}, + {file = "numpy-2.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a61ec659f68ae254e4d237816e33171497e978140353c0c2038d46e63282d0c8"}, + {file = "numpy-2.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d731a1c6116ba289c1e9ee714b08a8ff882944d4ad631fd411106a30f083c326"}, + {file = "numpy-2.0.2-cp310-cp310-win32.whl", hash = "sha256:984d96121c9f9616cd33fbd0618b7f08e0cfc9600a7ee1d6fd9b239186d19d97"}, + {file = "numpy-2.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:c7b0be4ef08607dd04da4092faee0b86607f111d5ae68036f16cc787e250a131"}, + {file = "numpy-2.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:49ca4decb342d66018b01932139c0961a8f9ddc7589611158cb3c27cbcf76448"}, + {file = "numpy-2.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:11a76c372d1d37437857280aa142086476136a8c0f373b2e648ab2c8f18fb195"}, + {file = "numpy-2.0.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:807ec44583fd708a21d4a11d94aedf2f4f3c3719035c76a2bbe1fe8e217bdc57"}, + {file = "numpy-2.0.2-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8cafab480740e22f8d833acefed5cc87ce276f4ece12fdaa2e8903db2f82897a"}, + {file = "numpy-2.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a15f476a45e6e5a3a79d8a14e62161d27ad897381fecfa4a09ed5322f2085669"}, + {file = "numpy-2.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13e689d772146140a252c3a28501da66dfecd77490b498b168b501835041f951"}, + {file = "numpy-2.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9ea91dfb7c3d1c56a0e55657c0afb38cf1eeae4544c208dc465c3c9f3a7c09f9"}, + {file = "numpy-2.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c1c9307701fec8f3f7a1e6711f9089c06e6284b3afbbcd259f7791282d660a15"}, + {file = "numpy-2.0.2-cp311-cp311-win32.whl", hash = "sha256:a392a68bd329eafac5817e5aefeb39038c48b671afd242710b451e76090e81f4"}, + {file = "numpy-2.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:286cd40ce2b7d652a6f22efdfc6d1edf879440e53e76a75955bc0c826c7e64dc"}, + {file = "numpy-2.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:df55d490dea7934f330006d0f81e8551ba6010a5bf035a249ef61a94f21c500b"}, + {file = "numpy-2.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8df823f570d9adf0978347d1f926b2a867d5608f434a7cff7f7908c6570dcf5e"}, + {file = "numpy-2.0.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9a92ae5c14811e390f3767053ff54eaee3bf84576d99a2456391401323f4ec2c"}, + {file = "numpy-2.0.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:a842d573724391493a97a62ebbb8e731f8a5dcc5d285dfc99141ca15a3302d0c"}, + {file = "numpy-2.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c05e238064fc0610c840d1cf6a13bf63d7e391717d247f1bf0318172e759e692"}, + {file = "numpy-2.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0123ffdaa88fa4ab64835dcbde75dcdf89c453c922f18dced6e27c90d1d0ec5a"}, + {file = "numpy-2.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:96a55f64139912d61de9137f11bf39a55ec8faec288c75a54f93dfd39f7eb40c"}, + {file = "numpy-2.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ec9852fb39354b5a45a80bdab5ac02dd02b15f44b3804e9f00c556bf24b4bded"}, + {file = "numpy-2.0.2-cp312-cp312-win32.whl", hash = "sha256:671bec6496f83202ed2d3c8fdc486a8fc86942f2e69ff0e986140339a63bcbe5"}, + {file = "numpy-2.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:cfd41e13fdc257aa5778496b8caa5e856dc4896d4ccf01841daee1d96465467a"}, + {file = "numpy-2.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9059e10581ce4093f735ed23f3b9d283b9d517ff46009ddd485f1747eb22653c"}, + {file = "numpy-2.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:423e89b23490805d2a5a96fe40ec507407b8ee786d66f7328be214f9679df6dd"}, + {file = "numpy-2.0.2-cp39-cp39-macosx_14_0_arm64.whl", hash = "sha256:2b2955fa6f11907cf7a70dab0d0755159bca87755e831e47932367fc8f2f2d0b"}, + {file = "numpy-2.0.2-cp39-cp39-macosx_14_0_x86_64.whl", hash = "sha256:97032a27bd9d8988b9a97a8c4d2c9f2c15a81f61e2f21404d7e8ef00cb5be729"}, + {file = "numpy-2.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e795a8be3ddbac43274f18588329c72939870a16cae810c2b73461c40718ab1"}, + {file = "numpy-2.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f26b258c385842546006213344c50655ff1555a9338e2e5e02a0756dc3e803dd"}, + {file = "numpy-2.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5fec9451a7789926bcf7c2b8d187292c9f93ea30284802a0ab3f5be8ab36865d"}, + {file = "numpy-2.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:9189427407d88ff25ecf8f12469d4d39d35bee1db5d39fc5c168c6f088a6956d"}, + {file = "numpy-2.0.2-cp39-cp39-win32.whl", hash = "sha256:905d16e0c60200656500c95b6b8dca5d109e23cb24abc701d41c02d74c6b3afa"}, + {file = "numpy-2.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:a3f4ab0caa7f053f6797fcd4e1e25caee367db3112ef2b6ef82d749530768c73"}, + {file = "numpy-2.0.2-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7f0a0c6f12e07fa94133c8a67404322845220c06a9e80e85999afe727f7438b8"}, + {file = "numpy-2.0.2-pp39-pypy39_pp73-macosx_14_0_x86_64.whl", hash = "sha256:312950fdd060354350ed123c0e25a71327d3711584beaef30cdaa93320c392d4"}, + {file = "numpy-2.0.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26df23238872200f63518dd2aa984cfca675d82469535dc7162dc2ee52d9dd5c"}, + {file = "numpy-2.0.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a46288ec55ebbd58947d31d72be2c63cbf839f0a63b49cb755022310792a3385"}, + {file = "numpy-2.0.2.tar.gz", hash = "sha256:883c987dee1880e2a864ab0dc9892292582510604156762362d9326444636e78"}, +] + +[[package]] +name = "packaging" +version = "24.2" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, + {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, +] + +[[package]] +name = "pathspec" +version = "0.12.1" +description = "Utility library for gitignore style pattern matching of file paths." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, + {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, +] + +[[package]] +name = "platformdirs" +version = "4.3.6" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb"}, + {file = "platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907"}, +] + +[package.extras] +docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4)"] +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 = "py4j" +version = "0.10.9.7" +description = "Enables Python programs to dynamically access arbitrary Java objects" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "py4j-0.10.9.7-py2.py3-none-any.whl", hash = "sha256:85defdfd2b2376eb3abf5ca6474b51ab7e0de341c75a02f46dc9b5976f5a5c1b"}, + {file = "py4j-0.10.9.7.tar.gz", hash = "sha256:0b6e5315bb3ada5cf62ac651d107bb2ebc02def3dee9d9548e3baac644ea8dbb"}, +] + +[[package]] +name = "pycodestyle" +version = "2.12.1" +description = "Python style guide checker" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pycodestyle-2.12.1-py2.py3-none-any.whl", hash = "sha256:46f0fb92069a7c28ab7bb558f05bfc0110dac69a0cd23c61ea0040283a9d78b3"}, + {file = "pycodestyle-2.12.1.tar.gz", hash = "sha256:6838eae08bbce4f6accd5d5572075c63626a15ee3e6f842df996bf62f6d73521"}, +] + +[[package]] +name = "pyflakes" +version = "3.2.0" +description = "passive checker of Python programs" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pyflakes-3.2.0-py2.py3-none-any.whl", hash = "sha256:84b5be138a2dfbb40689ca07e2152deb896a65c3a3e24c251c5c62489568074a"}, + {file = "pyflakes-3.2.0.tar.gz", hash = "sha256:1c61603ff154621fb2a9172037d84dca3500def8c8b630657d1701f026f8af3f"}, +] + +[[package]] +name = "pyspark" +version = "3.5.4" +description = "Apache Spark Python API" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pyspark-3.5.4.tar.gz", hash = "sha256:1c2926d63020902163f58222466adf6f8016f6c43c1f319b8e7a71dbaa05fc51"}, +] + +[package.dependencies] +py4j = "0.10.9.7" + +[package.extras] +connect = ["googleapis-common-protos (>=1.56.4)", "grpcio (>=1.56.0)", "grpcio-status (>=1.56.0)", "numpy (>=1.15,<2)", "pandas (>=1.0.5)", "pyarrow (>=4.0.0)"] +ml = ["numpy (>=1.15,<2)"] +mllib = ["numpy (>=1.15,<2)"] +pandas-on-spark = ["numpy (>=1.15,<2)", "pandas (>=1.0.5)", "pyarrow (>=4.0.0)"] +sql = ["numpy (>=1.15,<2)", "pandas (>=1.0.5)", "pyarrow (>=4.0.0)"] + +[[package]] +name = "tomli" +version = "2.2.1" +description = "A lil' TOML parser" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +markers = "python_version < \"3.11\"" +files = [ + {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, + {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8"}, + {file = "tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff"}, + {file = "tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b"}, + {file = "tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea"}, + {file = "tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e"}, + {file = "tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98"}, + {file = "tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4"}, + {file = "tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7"}, + {file = "tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744"}, + {file = "tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec"}, + {file = "tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69"}, + {file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"}, + {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"}, +] + +[[package]] +name = "typing-extensions" +version = "4.12.2" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +markers = "python_version < \"3.11\"" +files = [ + {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, + {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, +] + +[metadata] +lock-version = "2.1" +python-versions = ">=3.9 <3.13" +content-hash = "430f562a040c0eabc2fe5c93757801dd9d7ed4c5173be37c7fc3808e04668ccd" diff --git a/python/pyproject.toml b/python/pyproject.toml new file mode 100644 index 000000000..076cc3ce4 --- /dev/null +++ b/python/pyproject.toml @@ -0,0 +1,38 @@ +[tool.poetry] +name = "graphframes-py" +version = "0.8.4" +description = "GraphFrames: Graph Processing Framework for Apache Spark" +authors = ["GraphFrames Contributors "] +license = "Apache 2.0" +readme = "../README.md" +packages = [{include = "graphframes"}] + +[tool.poetry.urls] +"Project Homepage" = "https://graphframes.github.io/graphframes" +"PyPi Homepage" = "https://pypi.org/project/graphframes-py" +"Code Repository" = "https://github.com/graphframes/graphframes" +"Bug Tracker" = "https://github.com/graphframes/graphframes/issues" + +[tool.poetry.dependencies] +python = ">=3.9 <3.13" +nose = "1.3.7" +pyspark = ">= 2.0.0" +numpy = ">= 1.7" + +[tool.poetry.group.dev.dependencies] +black = "^25.1.0" +flake8 = "^7.1.1" +isort = "^6.0.0" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" + +[tool.black] +line-length = 100 +target-version = ["py39"] +include = ["graphframes", "test"] + +[tool.isort] +profile = "black" +src_paths = ["graphframes", "test"] From 3cea1a88e85b54662a9126dc56ab52c625ca7b3a Mon Sep 17 00:00:00 2001 From: Russell Jurney Date: Sun, 16 Feb 2025 14:24:58 -0800 Subject: [PATCH 17/70] Short README for Python package, poetry won't allow a ../README.md path --- python/README.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 python/README.md diff --git a/python/README.md b/python/README.md new file mode 100644 index 000000000..0ab7cd4ba --- /dev/null +++ b/python/README.md @@ -0,0 +1,17 @@ +# GraphFrames `graphframes-py` Python Package + +The is the officila [graphframes-py PyPI package](https://pypi.org/project/graphframes-py/), which is a Python wrapper for the Scala GraphFrames library. This package is maintained by the GraphFrames project and is available on PyPI. + +For instructions on GraphFrames, check the project [../README.md](../README.md). See [Installation and Quick-Start](#installation-and-quick-start) for the best way to install and use GraphFrames. + +## Running `graphframes-py` + +You should use GraphFrames via the `--packages` argument to `pyspark` or `spark-submit`, but this package is helpful in development environments. + +```bash +# Interactive Python +$ pyspark --packages graphframes:graphframes:0.8.4-spark3.5-s_2.12 + +# Submit a script in Scala/Java/Python +$ spark-submit --packages graphframes:graphframes:0.8.4-spark3.5-s_2.12 script.py +``` From 87cc97514c4aa7e1d76b5a3bb80fd5ee4e2abf50 Mon Sep 17 00:00:00 2001 From: Russell Jurney Date: Sun, 16 Feb 2025 14:25:41 -0800 Subject: [PATCH 18/70] Remove requirements files in favor of pyproject.toml --- python/requirements-dev.txt | 4 ---- python/requirements.txt | 4 ---- 2 files changed, 8 deletions(-) delete mode 100644 python/requirements-dev.txt delete mode 100644 python/requirements.txt diff --git a/python/requirements-dev.txt b/python/requirements-dev.txt deleted file mode 100644 index 6e596dc62..000000000 --- a/python/requirements-dev.txt +++ /dev/null @@ -1,4 +0,0 @@ -flake8==7.1.1 -isort==6.0.0 -mypy==1.14.1 -pre-commit==4.0.1 diff --git a/python/requirements.txt b/python/requirements.txt deleted file mode 100644 index 9893b3cb1..000000000 --- a/python/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -# This file should list any python package dependencies. -nose==1.3.7 -pyspark>=2.0.0 -numpy>=1.7 From 6f84a5a634bcdf731c469644a3509074c3ce58d7 Mon Sep 17 00:00:00 2001 From: Russell Jurney Date: Sun, 16 Feb 2025 14:33:18 -0800 Subject: [PATCH 19/70] Try to poetrize CI build --- .github/workflows/python-ci.yml | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml index 157b328f1..47a484c1e 100644 --- a/.github/workflows/python-ci.yml +++ b/.github/workflows/python-ci.yml @@ -31,15 +31,13 @@ jobs: - uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - - name: Install python dependencies + - name: Build Python package and its dependencies + working-directory: ./python run: | - python -m pip install --upgrade pip wheel - pip install -r ./python/requirements.txt - pip install -r ./python/requirements-dev.txt - pip install pyspark==${{ matrix.spark-version }} + python -m pip install --upgrade poetry + poetry build + poetry install - name: Test run: | - python python/setup.py install - python python/setup.py bdist_wheel export SPARK_HOME=$(python -c "import os; from importlib.util import find_spec; print(os.path.join(os.path.dirname(find_spec('pyspark').origin)))") ./python/run-tests.sh From 9a8eef0d29e2d36129427ae9efa13fc8bb044021 Mon Sep 17 00:00:00 2001 From: Russell Jurney Date: Sun, 16 Feb 2025 15:01:09 -0800 Subject: [PATCH 20/70] pyspark min 3.4 --- python/poetry.lock | 2 +- python/pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/python/poetry.lock b/python/poetry.lock index 6eb61618d..0fb5fb139 100644 --- a/python/poetry.lock +++ b/python/poetry.lock @@ -357,4 +357,4 @@ files = [ [metadata] lock-version = "2.1" python-versions = ">=3.9 <3.13" -content-hash = "430f562a040c0eabc2fe5c93757801dd9d7ed4c5173be37c7fc3808e04668ccd" +content-hash = "52c129fee3e94e69edf727f219bc7582ddbfcedf6c43547a7f67a876051bd7c4" diff --git a/python/pyproject.toml b/python/pyproject.toml index 076cc3ce4..0cff88d08 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -16,7 +16,7 @@ packages = [{include = "graphframes"}] [tool.poetry.dependencies] python = ">=3.9 <3.13" nose = "1.3.7" -pyspark = ">= 2.0.0" +pyspark = "^3.4" numpy = ">= 1.7" [tool.poetry.group.dev.dependencies] From 75ecd997d2cfbf0e52799b86b3f9f261e63e375e Mon Sep 17 00:00:00 2001 From: Russell Jurney Date: Sun, 16 Feb 2025 15:02:53 -0800 Subject: [PATCH 21/70] Local python README in pyproject.toml --- python/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/pyproject.toml b/python/pyproject.toml index 0cff88d08..84050dcc2 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -4,7 +4,7 @@ version = "0.8.4" description = "GraphFrames: Graph Processing Framework for Apache Spark" authors = ["GraphFrames Contributors "] license = "Apache 2.0" -readme = "../README.md" +readme = "README.md" packages = [{include = "graphframes"}] [tool.poetry.urls] From 80231d0e2262eb1044a619dbd2792f6cdcc41d35 Mon Sep 17 00:00:00 2001 From: Russell Jurney Date: Sun, 16 Feb 2025 15:23:20 -0800 Subject: [PATCH 22/70] Trying to remove he working folder to debug scala issue --- .github/workflows/python-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml index 47a484c1e..3af7339b0 100644 --- a/.github/workflows/python-ci.yml +++ b/.github/workflows/python-ci.yml @@ -32,7 +32,7 @@ jobs: with: python-version: ${{ matrix.python-version }} - name: Build Python package and its dependencies - working-directory: ./python + # working-directory: ./python run: | python -m pip install --upgrade poetry poetry build From 2a9170baad6e6d2791f258841b7db54cecec251d Mon Sep 17 00:00:00 2001 From: Russell Jurney Date: Sun, 16 Feb 2025 15:50:44 -0800 Subject: [PATCH 23/70] Set Python working directory again --- .github/workflows/python-ci.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml index 3af7339b0..f863785b0 100644 --- a/.github/workflows/python-ci.yml +++ b/.github/workflows/python-ci.yml @@ -10,6 +10,7 @@ jobs: scala-version: 2.12.18 python-version: 3.9.19 runs-on: ubuntu-22.04 + env: # define Java options for both official sbt and sbt-extras JAVA_OPTS: -Xms2048M -Xmx2048M -Xss6M -XX:ReservedCodeCacheSize=256M -Dfile.encoding=UTF-8 @@ -32,7 +33,7 @@ jobs: with: python-version: ${{ matrix.python-version }} - name: Build Python package and its dependencies - # working-directory: ./python + working-directory: ./python run: | python -m pip install --upgrade poetry poetry build From 3de22636760c3059361efd5c6135c99236c88949 Mon Sep 17 00:00:00 2001 From: Russell Jurney Date: Sun, 16 Feb 2025 15:51:43 -0800 Subject: [PATCH 24/70] Accidental newline --- .github/workflows/python-ci.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml index f863785b0..47a484c1e 100644 --- a/.github/workflows/python-ci.yml +++ b/.github/workflows/python-ci.yml @@ -10,7 +10,6 @@ jobs: scala-version: 2.12.18 python-version: 3.9.19 runs-on: ubuntu-22.04 - env: # define Java options for both official sbt and sbt-extras JAVA_OPTS: -Xms2048M -Xmx2048M -Xss6M -XX:ReservedCodeCacheSize=256M -Dfile.encoding=UTF-8 From 4662717935fd3629a237d1ab454ba6fc6b42327f Mon Sep 17 00:00:00 2001 From: Russell Jurney Date: Sun, 16 Feb 2025 16:10:08 -0800 Subject: [PATCH 25/70] Install Python for test... --- .github/workflows/python-ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml index 47a484c1e..519c5fb4a 100644 --- a/.github/workflows/python-ci.yml +++ b/.github/workflows/python-ci.yml @@ -39,5 +39,8 @@ jobs: poetry install - name: Test run: | + python -m pip install --upgrade poetry + poetry build + poetry install export SPARK_HOME=$(python -c "import os; from importlib.util import find_spec; print(os.path.join(os.path.dirname(find_spec('pyspark').origin)))") ./python/run-tests.sh From 1b7b9f83a82cb120cd831cbeb38a71065d9030fd Mon Sep 17 00:00:00 2001 From: Russell Jurney Date: Sun, 16 Feb 2025 16:19:15 -0800 Subject: [PATCH 26/70] Run tests from python/ folder --- .github/workflows/python-ci.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml index 519c5fb4a..72ffe6e22 100644 --- a/.github/workflows/python-ci.yml +++ b/.github/workflows/python-ci.yml @@ -38,9 +38,7 @@ jobs: poetry build poetry install - name: Test + working-directory: ./python run: | - python -m pip install --upgrade poetry - poetry build - poetry install export SPARK_HOME=$(python -c "import os; from importlib.util import find_spec; print(os.path.join(os.path.dirname(find_spec('pyspark').origin)))") ./python/run-tests.sh From 58da4932cb3997b797a8ec9f98e6bd95e49f543e Mon Sep 17 00:00:00 2001 From: Russell Jurney Date: Sun, 16 Feb 2025 16:37:58 -0800 Subject: [PATCH 27/70] Try running tests from python/ --- .github/workflows/python-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml index 72ffe6e22..2e3e44311 100644 --- a/.github/workflows/python-ci.yml +++ b/.github/workflows/python-ci.yml @@ -41,4 +41,4 @@ jobs: working-directory: ./python run: | export SPARK_HOME=$(python -c "import os; from importlib.util import find_spec; print(os.path.join(os.path.dirname(find_spec('pyspark').origin)))") - ./python/run-tests.sh + ./run-tests.sh From 9f4aa24e6d77ccb45c42b4ea8bf02b1905e826a1 Mon Sep 17 00:00:00 2001 From: Russell Jurney Date: Sun, 16 Feb 2025 16:45:10 -0800 Subject: [PATCH 28/70] poetry run the unit tests --- .github/workflows/python-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml index 2e3e44311..3d939db65 100644 --- a/.github/workflows/python-ci.yml +++ b/.github/workflows/python-ci.yml @@ -40,5 +40,5 @@ jobs: - name: Test working-directory: ./python run: | - export SPARK_HOME=$(python -c "import os; from importlib.util import find_spec; print(os.path.join(os.path.dirname(find_spec('pyspark').origin)))") + export SPARK_HOME=$(poetry run python -c "import os; from importlib.util import find_spec; spec = find_spec('pyspark'); print(os.path.join(os.path.dirname(spec.origin)))") ./run-tests.sh From 11b2782e519a518287b62b8e6969bc7b2f2f0947 Mon Sep 17 00:00:00 2001 From: Russell Jurney Date: Sun, 16 Feb 2025 16:49:18 -0800 Subject: [PATCH 29/70] poetry run the tests --- python/run-tests.sh | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/python/run-tests.sh b/python/run-tests.sh index af4e0a139..4c558dfd3 100755 --- a/python/run-tests.sh +++ b/python/run-tests.sh @@ -38,7 +38,7 @@ echo $pyver LIBS="" for lib in "$SPARK_HOME/python/lib"/*zip ; do - LIBS=$LIBS:$lib + LIBS=$LIBS:$lib done # The current directory of the script. @@ -51,7 +51,7 @@ assembly_path="$DIR/../target/scala-$scala_version_major_minor" echo `ls $assembly_path/graphframes-assembly*.jar` JAR_PATH="" for assembly in $assembly_path/graphframes-assembly*.jar ; do - JAR_PATH=$assembly + JAR_PATH=$assembly done export PYSPARK_SUBMIT_ARGS="--driver-memory 2g --executor-memory 2g --jars $JAR_PATH pyspark-shell " @@ -64,14 +64,14 @@ export PYTHONPATH=$PYTHONPATH:graphframes # Run test suites if [[ "$python_major" == "2" ]]; then - - # Horrible hack for spark 1.x: we manually remove some log lines to stay below the 4MB log limit on Travis. - $PYSPARK_DRIVER_PYTHON `which nosetests` -v --all-modules -w $DIR 2>&1 | grep -vE "INFO (ParquetOutputFormat|SparkContext|ContextCleaner|ShuffleBlockFetcherIterator|MapOutputTrackerMaster|TaskSetManager|Executor|MemoryStore|CacheManager|BlockManager|DAGScheduler|PythonRDD|TaskSchedulerImpl|ZippedPartitionsRDD2)"; - + + # Horrible hack for spark 1.x: we manually remove some log lines to stay below the 4MB log limit on Travis. + poetry run $PYSPARK_DRIVER_PYTHON `which nosetests` -v --all-modules -w $DIR 2>&1 | grep -vE "INFO (ParquetOutputFormat|SparkContext|ContextCleaner|ShuffleBlockFetcherIterator|MapOutputTrackerMaster|TaskSetManager|Executor|MemoryStore|CacheManager|BlockManager|DAGScheduler|PythonRDD|TaskSchedulerImpl|ZippedPartitionsRDD2)"; + else - - $PYSPARK_DRIVER_PYTHON -m "nose" -v --all-modules -w $DIR 2>&1 | grep -vE "INFO (ParquetOutputFormat|SparkContext|ContextCleaner|ShuffleBlockFetcherIterator|MapOutputTrackerMaster|TaskSetManager|Executor|MemoryStore|CacheManager|BlockManager|DAGScheduler|PythonRDD|TaskSchedulerImpl|ZippedPartitionsRDD2)"; - + + poetry run $PYSPARK_DRIVER_PYTHON -m "nose" -v --all-modules -w $DIR 2>&1 | grep -vE "INFO (ParquetOutputFormat|SparkContext|ContextCleaner|ShuffleBlockFetcherIterator|MapOutputTrackerMaster|TaskSetManager|Executor|MemoryStore|CacheManager|BlockManager|DAGScheduler|PythonRDD|TaskSchedulerImpl|ZippedPartitionsRDD2)"; + fi # Exit immediately if the tests fail. From 9772344b96fb353a3f7ad17f9198a28ee0aef568 Mon Sep 17 00:00:00 2001 From: Russell Jurney Date: Sun, 16 Feb 2025 16:52:30 -0800 Subject: [PATCH 30/70] Try just using 'python' instead of a path --- python/run-tests.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/python/run-tests.sh b/python/run-tests.sh index 4c558dfd3..6527a60a7 100755 --- a/python/run-tests.sh +++ b/python/run-tests.sh @@ -66,11 +66,11 @@ export PYTHONPATH=$PYTHONPATH:graphframes if [[ "$python_major" == "2" ]]; then # Horrible hack for spark 1.x: we manually remove some log lines to stay below the 4MB log limit on Travis. - poetry run $PYSPARK_DRIVER_PYTHON `which nosetests` -v --all-modules -w $DIR 2>&1 | grep -vE "INFO (ParquetOutputFormat|SparkContext|ContextCleaner|ShuffleBlockFetcherIterator|MapOutputTrackerMaster|TaskSetManager|Executor|MemoryStore|CacheManager|BlockManager|DAGScheduler|PythonRDD|TaskSchedulerImpl|ZippedPartitionsRDD2)"; + poetry run python `which nosetests` -v --all-modules -w $DIR 2>&1 | grep -vE "INFO (ParquetOutputFormat|SparkContext|ContextCleaner|ShuffleBlockFetcherIterator|MapOutputTrackerMaster|TaskSetManager|Executor|MemoryStore|CacheManager|BlockManager|DAGScheduler|PythonRDD|TaskSchedulerImpl|ZippedPartitionsRDD2)"; else - poetry run $PYSPARK_DRIVER_PYTHON -m "nose" -v --all-modules -w $DIR 2>&1 | grep -vE "INFO (ParquetOutputFormat|SparkContext|ContextCleaner|ShuffleBlockFetcherIterator|MapOutputTrackerMaster|TaskSetManager|Executor|MemoryStore|CacheManager|BlockManager|DAGScheduler|PythonRDD|TaskSchedulerImpl|ZippedPartitionsRDD2)"; + poetry run python -m "nose" -v --all-modules -w $DIR 2>&1 | grep -vE "INFO (ParquetOutputFormat|SparkContext|ContextCleaner|ShuffleBlockFetcherIterator|MapOutputTrackerMaster|TaskSetManager|Executor|MemoryStore|CacheManager|BlockManager|DAGScheduler|PythonRDD|TaskSchedulerImpl|ZippedPartitionsRDD2)"; fi From d55dbfe4815c2f0b0870cdc53b65fb9d9a075b42 Mon Sep 17 00:00:00 2001 From: Russell Jurney Date: Sun, 16 Feb 2025 16:58:08 -0800 Subject: [PATCH 31/70] poetry run the last line, graphframes.main --- python/run-tests.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/run-tests.sh b/python/run-tests.sh index 6527a60a7..0382efbd0 100755 --- a/python/run-tests.sh +++ b/python/run-tests.sh @@ -83,4 +83,4 @@ test ${PIPESTATUS[0]} -eq 0 || exit 1; cd "$DIR" -$PYSPARK_PYTHON -u ./graphframes/graphframe.py "$@" +poetry run python -u ./graphframes/graphframe.py "$@" From 2fc4d0818f35a86874b67f86893f48b5f83d7285 Mon Sep 17 00:00:00 2001 From: Russell Jurney Date: Sun, 16 Feb 2025 16:59:23 -0800 Subject: [PATCH 32/70] Remove test/ folder from style paths, it doesn't exist --- python/pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/python/pyproject.toml b/python/pyproject.toml index 84050dcc2..e21c4cc80 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -31,8 +31,8 @@ build-backend = "poetry.core.masonry.api" [tool.black] line-length = 100 target-version = ["py39"] -include = ["graphframes", "test"] +include = ["graphframes"] [tool.isort] profile = "black" -src_paths = ["graphframes", "test"] +src_paths = ["graphframes"] From 8297a13232f29f9466f8c0ac3bd577e2cbb066ea Mon Sep 17 00:00:00 2001 From: Russell Jurney Date: Sun, 16 Feb 2025 17:18:13 -0800 Subject: [PATCH 33/70] Remove .vscode --- .gitignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitignore b/.gitignore index 7036d69e3..93246acbe 100644 --- a/.gitignore +++ b/.gitignore @@ -27,7 +27,6 @@ project/plugins/project/ # Mac *.DS_Store -.vscode # Python specific python/build From 2035d9854344a53ce4ba77c1d6a4f7478763f963 Mon Sep 17 00:00:00 2001 From: Russell Jurney Date: Sun, 16 Feb 2025 17:18:42 -0800 Subject: [PATCH 34/70] VERSION back to 0.8.4 --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 7ada0d303..b60d71966 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.8.5 +0.8.4 From f9f4bd7b9dbdf0bf18e1dde83090bdc59d5fc23d Mon Sep 17 00:00:00 2001 From: Russell Jurney Date: Sun, 16 Feb 2025 17:19:55 -0800 Subject: [PATCH 35/70] Remove tutorials reference --- python/MANIFEST.in | 1 - 1 file changed, 1 deletion(-) diff --git a/python/MANIFEST.in b/python/MANIFEST.in index 4eb0ee5af..8e453d713 100644 --- a/python/MANIFEST.in +++ b/python/MANIFEST.in @@ -5,4 +5,3 @@ recursive-include python/graphframes *.py recursive-exclude * __pycache__ recursive-exclude * *.pyc -include graphframes/tutorials/data/.exists From 9ddd6b24cefc4528a9bfa75e8d8ddf3d365b8eaf Mon Sep 17 00:00:00 2001 From: Russell Jurney Date: Sun, 16 Feb 2025 17:23:35 -0800 Subject: [PATCH 36/70] VERSION is a Python thing, it belongs in python/ --- VERSION => python/VERSION | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename VERSION => python/VERSION (100%) diff --git a/VERSION b/python/VERSION similarity index 100% rename from VERSION rename to python/VERSION From 7065647d6cf0be0513af34bf355aea21ff5a2090 Mon Sep 17 00:00:00 2001 From: Russell Jurney Date: Sun, 16 Feb 2025 17:33:27 -0800 Subject: [PATCH 37/70] Include the README.md and LICENSE in the Python package --- python/MANIFEST.in | 2 ++ 1 file changed, 2 insertions(+) diff --git a/python/MANIFEST.in b/python/MANIFEST.in index 8e453d713..f883d48c1 100644 --- a/python/MANIFEST.in +++ b/python/MANIFEST.in @@ -5,3 +5,5 @@ recursive-include python/graphframes *.py recursive-exclude * __pycache__ recursive-exclude * *.pyc +include README.md +include LICENSE From a6c7e91f151ae7f04268f98c52aed995855e881f Mon Sep 17 00:00:00 2001 From: Russell Jurney Date: Sun, 16 Feb 2025 17:34:21 -0800 Subject: [PATCH 38/70] Some classifiers for pyproject.toml --- python/pyproject.toml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/python/pyproject.toml b/python/pyproject.toml index e21c4cc80..8c0c1ba05 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -6,6 +6,16 @@ authors = ["GraphFrames Contributors "] license = "Apache 2.0" readme = "README.md" packages = [{include = "graphframes"}] +classifiers = [ + "Development Status :: 4 - Beta", + "License :: OSI Approved :: Apache Software License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12" +] [tool.poetry.urls] "Project Homepage" = "https://graphframes.github.io/graphframes" From 51e3e6d95d312d83e01d91151bcc90e5e9a63edf Mon Sep 17 00:00:00 2001 From: Russell Jurney Date: Mon, 17 Feb 2025 08:49:21 -0800 Subject: [PATCH 39/70] Trying poetry install action instead of manual install --- .github/workflows/python-ci.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml index 3d939db65..1095ce49e 100644 --- a/.github/workflows/python-ci.yml +++ b/.github/workflows/python-ci.yml @@ -31,10 +31,16 @@ jobs: - uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} + - name: Install and configure Poetry + uses: snok/install-poetry@v1 + with: + version: 2.1.1 + virtualenvs-create: true + virtualenvs-in-project: false + installer-parallel: true - name: Build Python package and its dependencies working-directory: ./python run: | - python -m pip install --upgrade poetry poetry build poetry install - name: Test From 272be064e60f3c07817533a1e02b5a0eec2b89cf Mon Sep 17 00:00:00 2001 From: Russell Jurney Date: Mon, 17 Feb 2025 08:53:55 -0800 Subject: [PATCH 40/70] Removing SPARK_HOME --- .github/workflows/python-ci.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml index 1095ce49e..7f201a049 100644 --- a/.github/workflows/python-ci.yml +++ b/.github/workflows/python-ci.yml @@ -46,5 +46,4 @@ jobs: - name: Test working-directory: ./python run: | - export SPARK_HOME=$(poetry run python -c "import os; from importlib.util import find_spec; spec = find_spec('pyspark'); print(os.path.join(os.path.dirname(spec.origin)))") ./run-tests.sh From 45879995d1c6c6bc22a9f82b59290f7912b5ba3b Mon Sep 17 00:00:00 2001 From: Russell Jurney Date: Mon, 17 Feb 2025 09:46:00 -0800 Subject: [PATCH 41/70] Returned SPARK_HOME settings --- .github/workflows/python-ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml index 7f201a049..1095ce49e 100644 --- a/.github/workflows/python-ci.yml +++ b/.github/workflows/python-ci.yml @@ -46,4 +46,5 @@ jobs: - name: Test working-directory: ./python run: | + export SPARK_HOME=$(poetry run python -c "import os; from importlib.util import find_spec; spec = find_spec('pyspark'); print(os.path.join(os.path.dirname(spec.origin)))") ./run-tests.sh From 2422b226b341cdf728fdaaf9ca109833d6ad11fe Mon Sep 17 00:00:00 2001 From: Russell Jurney Date: Mon, 17 Feb 2025 10:06:54 -0800 Subject: [PATCH 42/70] Minimized the PR to just these files --- python/MANIFEST.in | 1 + python/graphframes/tutorials/download.py | 64 ++ python/graphframes/tutorials/motif.py | 207 +++++++ python/graphframes/tutorials/stackexchange.py | 579 ++++++++++++++++++ python/graphframes/tutorials/utils.py | 122 ++++ 5 files changed, 973 insertions(+) create mode 100755 python/graphframes/tutorials/download.py create mode 100644 python/graphframes/tutorials/motif.py create mode 100644 python/graphframes/tutorials/stackexchange.py create mode 100644 python/graphframes/tutorials/utils.py diff --git a/python/MANIFEST.in b/python/MANIFEST.in index 73eaf8ba2..22100a328 100644 --- a/python/MANIFEST.in +++ b/python/MANIFEST.in @@ -2,3 +2,4 @@ # https://github.com/pypa/sampleproject/blob/master/MANIFEST.in # For more details about the MANIFEST file, you may read the docs at # https://docs.python.org/2/distutils/sourcedist.html#the-manifest-in-template +include graphframes/tutorials/data/.exists diff --git a/python/graphframes/tutorials/download.py b/python/graphframes/tutorials/download.py new file mode 100755 index 000000000..154d84c14 --- /dev/null +++ b/python/graphframes/tutorials/download.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python + +import os +import click +import requests +import py7zr + + +@click.command() +@click.argument("subdomain") +@click.option("--data-dir", default="python/graphframes/tutorials/data", help="Directory to store downloaded files") +@click.option( + "--extract/--no-extract", default=True, help="Whether to extract the archive after download" +) +def download_stackexchange(subdomain: str, data_dir: str, extract: bool) -> None: + """Download Stack Exchange archive for a given SUBDOMAIN. + + Example: python/graphframes/tutorials/download.py stats.meta + + Note: This won't work for stackoverflow.com archives due to size. + """ + # Create data directory if it doesn't exist + os.makedirs(data_dir, exist_ok=True) + + # Construct archive URL and filename + archive_url = f"https://archive.org/download/stackexchange/{subdomain}.stackexchange.com.7z" + archive_path = os.path.join(data_dir, f"{subdomain}.stackexchange.com.7z") + + click.echo(f"Downloading archive from {archive_url}") + + try: + # Download the file + response = requests.get(archive_url, stream=True) + response.raise_for_status() # Raise exception for bad status codes + + total_size = int(response.headers.get("content-length", 0)) + + with click.progressbar(length=total_size, label="Downloading") as bar: + with open(archive_path, "wb") as f: + for chunk in response.iter_content(chunk_size=8192): + if chunk: + f.write(chunk) + bar.update(len(chunk)) + + click.echo(f"Download complete: {archive_path}") + + # Extract if requested + if extract: + click.echo("Extracting archive...") + output_dir = f"{subdomain}.stackexchange.com" + with py7zr.SevenZipFile(archive_path, mode="r") as z: + z.extractall(path=os.path.join(data_dir, output_dir)) + click.echo(f"Extraction complete: {output_dir}") + + except requests.exceptions.RequestException as e: + click.echo(f"Error downloading archive: {e}", err=True) + raise click.Abort() + except py7zr.Bad7zFile as e: + click.echo(f"Error extracting archive: {e}", err=True) + raise click.Abort() + + +if __name__ == "__main__": + download_stackexchange() diff --git a/python/graphframes/tutorials/motif.py b/python/graphframes/tutorials/motif.py new file mode 100644 index 000000000..4a2189c56 --- /dev/null +++ b/python/graphframes/tutorials/motif.py @@ -0,0 +1,207 @@ +# Demonstrate GraphFrames network motif finding capabilities + +# +# Interactive Usage: pyspark --packages graphframes:graphframes:0.8.4-spark3.5-s_2.12 +# +# Batch Usage: spark-submit --packages graphframes:graphframes:0.8.4-spark3.5-s_2.12 python/graphframes/tutorials/motif.py +# + +import pyspark.sql.functions as F +from pyspark import SparkContext +from pyspark.sql import DataFrame, SparkSession + +from graphframes import GraphFrame + +# Initialize a SparkSession +spark: SparkSession = ( + SparkSession.builder.appName("Stack Overflow Motif Analysis") + # Lets the Id:(Stack Overflow int) and id:(GraphFrames ULID) coexist + .config("spark.sql.caseSensitive", True).getOrCreate() +) +sc: SparkContext = spark.sparkContext +sc.setCheckpointDir("/tmp/graphframes-checkpoints") + +# Change me if you download a different stackexchange site +STACKEXCHANGE_SITE = "stats.meta.stackexchange.com" +BASE_PATH = f"python/graphframes/tutorials/data/{STACKEXCHANGE_SITE}" + +# +# Load the nodes and edges from disk, repartition, checkpoint [plan got long for some reason] and cache. +# + +# We created these in stackexchange.py from Stack Exchange data dump XML files +NODES_PATH: str = f"{BASE_PATH}/Nodes.parquet" +nodes_df: DataFrame = spark.read.parquet(NODES_PATH) + +# Repartition the nodes to give our motif searches parallelism +nodes_df = nodes_df.repartition(50).checkpoint().cache() + +# We created these in stackexchange.py from Stack Exchange data dump XML files +EDGES_PATH: str = f"{BASE_PATH}/Edges.parquet" +edges_df: DataFrame = spark.read.parquet(EDGES_PATH) + +# Repartition the edges to give our motif searches parallelism +edges_df = edges_df.repartition(50).checkpoint().cache() + +# What kind of nodes we do we have to work with? +node_counts = ( + nodes_df.select("id", F.col("Type").alias("Node Type")) + .groupBy("Node Type") + .count() + .orderBy(F.col("count").desc()) + # Add a comma formatted column for display + .withColumn("count", F.format_number(F.col("count"), 0)) +) +node_counts.show() + +# What kind of edges do we have to work with? +edge_counts = ( + edges_df.select("src", "dst", F.col("relationship").alias("Edge Type")) + .groupBy("Edge Type") + .count() + .orderBy(F.col("count").desc()) + # Add a comma formatted column for display + .withColumn("count", F.format_number(F.col("count"), 0)) +) +edge_counts.show() + +g = GraphFrame(nodes_df, edges_df) + +g.vertices.show(10) +print(f"Node columns: {g.vertices.columns}") + +g.edges.sample(0.0001).show(10) + +# Sanity test that all edges have valid ids +edge_count = g.edges.count() +valid_edge_count = ( + g.edges.join(g.vertices, on=g.edges.src == g.vertices.id) + .select("src", "dst", "relationship") + .join(g.vertices, on=g.edges.dst == g.vertices.id) + .count() +) + +# Just up and die if we have edges that point to non-existent nodes +assert ( + edge_count == valid_edge_count +), f"Edge count {edge_count} != valid edge count {valid_edge_count}" +print(f"Edge count: {edge_count:,} == Valid edge count: {valid_edge_count:,}") + +# G4: Continuous Triangles +paths = g.find("(a)-[e1]->(b); (b)-[e2]->(c); (c)-[e3]->(a)") + +# Show the first path +paths.show(3) + +graphlet_type_df = paths.select( + F.col("a.Type").alias("A_Type"), + F.col("e1.relationship").alias("(a)-[e1]->(b)"), + F.col("b.Type").alias("B_Type"), + F.col("e2.relationship").alias("(b)-[e2]->(c)"), + F.col("c.Type").alias("C_Type"), + F.col("e3.relationship").alias("(c)-[e3]->(a)"), +) + +graphlet_count_df = ( + graphlet_type_df.groupby( + "A_Type", "(a)-[e1]->(b)", "B_Type", "(b)-[e2]->(c)", "C_Type", "(c)-[e3]->(a)" + ) + .count() + .orderBy(F.col("count").desc()) + # Add a comma formatted column for display + .withColumn("count", F.format_number(F.col("count"), 0)) +) +graphlet_count_df.show() + +# G5: Divergent Triangles +paths = g.find("(a)-[e1]->(b); (a)-[e2]->(c); (c)-[e3]->(b)") + +graphlet_type_df = paths.select( + F.col("a.Type").alias("A_Type"), + F.col("e1.relationship").alias("(a)-[e1]->(b)"), + F.col("b.Type").alias("B_Type"), + F.col("e2.relationship").alias("(a)-[e2]->(c)"), + F.col("c.Type").alias("C_Type"), + F.col("e3.relationship").alias("(c)-[e3]->(b)"), +) + +graphlet_count_df = ( + graphlet_type_df.groupby( + "A_Type", "(a)-[e1]->(b)", "B_Type", "(a)-[e2]->(c)", "C_Type", "(c)-[e3]->(b)" + ) + .count() + .orderBy(F.col("count").desc()) + # Add a comma formatted column for display + .withColumn("count", F.format_number(F.col("count"), 0)) +) +graphlet_count_df.show() + +# G17: A directed 3-path is a surprisingly diverse graphlet +paths = g.find("(a)-[e1]->(b); (b)-[e2]->(c); (d)-[e3]->(c)") + +# Visualize the four-path by counting instances of paths by node / edge type +graphlet_type_df = paths.select( + F.col("a.Type").alias("A_Type"), + F.col("e1.relationship").alias("(a)-[e1]->(b)"), + F.col("b.Type").alias("B_Type"), + F.col("e2.relationship").alias("(b)-[e2]->(c)"), + F.col("c.Type").alias("C_Type"), + F.col("e3.relationship").alias("(d)-[e3]->(c)"), + F.col("d.Type").alias("D_Type"), +) +graphlet_count_df = ( + graphlet_type_df.groupby( + "A_Type", + "(a)-[e1]->(b)", + "B_Type", + "(b)-[e2]->(c)", + "C_Type", + "(d)-[e3]->(c)", + "D_Type", + ) + .count() + .orderBy(F.col("count").desc()) + # Add a comma formatted column for display + .withColumn("count", F.format_number(F.col("count"), 0)) +) +graphlet_count_df.show() + +graphlet_count_df.orderBy( + [ + "A_Type", + "(a)-[e1]->(b)", + "B_Type", + "(b)-[e2]->(c)", + "C_Type", + "(d)-[e3]->(c)", + "D_Type", + ], + ascending=False, +).show(104) + +# A user answers an answer that answers a question that links to an answer. +linked_vote_paths = paths.filter( + (F.col("a.Type") == "Vote") + & (F.col("e1.relationship") == "CastFor") + & (F.col("b.Type") == "Question") + & (F.col("e2.relationship") == "Links") + & (F.col("c.Type") == "Question") + & (F.col("e3.relationship") == "CastFor") + & (F.col("d.Type") == "Vote") +) + +# Sanity check the count - it should match the table above +linked_vote_paths.count() + +b_vote_counts = linked_vote_paths.select("a", "b").distinct().groupBy("b").count() +c_vote_counts = linked_vote_paths.select("c", "d").distinct().groupBy("c").count() + +linked_vote_counts = ( + linked_vote_paths.filter((F.col("a.VoteTypeId") == 2) & (F.col("d.VoteTypeId") == 2)) + .select("b", "c") + .join(b_vote_counts, on="b", how="inner") + .withColumnRenamed("count", "b_count") + .join(c_vote_counts, on="c", how="inner") + .withColumnRenamed("count", "c_count") +) +linked_vote_counts.stat.corr("b_count", "c_count") diff --git a/python/graphframes/tutorials/stackexchange.py b/python/graphframes/tutorials/stackexchange.py new file mode 100644 index 000000000..c52f323bb --- /dev/null +++ b/python/graphframes/tutorials/stackexchange.py @@ -0,0 +1,579 @@ +# Build a Graph out of the Stack Exchange Data Dump XML files + +# +# Interactive Usage: pyspark --packages com.databricks:spark-xml_2.12:0.18.0 +# +# Batch Usage: spark-submit --packages com.databricks:spark-xml_2.12:0.18.0 python/graphframes/tutorials/stackexchange.py +# + +import re +from typing import List, Tuple + +import pyspark.sql.functions as F +import pyspark.sql.types as T +from pyspark.sql import DataFrame, SparkSession + +# Change me if you download a different stackexchange site +STACKEXCHANGE_SITE = "stats.meta.stackexchange.com" +BASE_PATH = f"python/graphframes/tutorials/data/{STACKEXCHANGE_SITE}" + + +# +# Some utility functions +# + + +def remove_prefix(df: DataFrame) -> DataFrame: + """Remove the _ prefix present in the fields of the DataFrame""" + field_names = [x.name for x in df.schema] + new_field_names = [x[1:] for x in field_names] + s = [] + + # Substitute the old name for the new one + for old, new in zip(field_names, new_field_names): + s.append(F.col(old).alias(new)) + return df.select(s) + + +@F.udf(returnType=T.ArrayType(T.StringType())) +def split_tags(tags: str) -> List[str]: + if not tags: + return [] + # Remove < and > and split into array + return re.findall(r"<([^>]+)>", tags) + + +# +# Initialize a SparkSession with case sensitivity +# + +spark: SparkSession = ( + SparkSession.builder.appName("Stack Exchange Graph Builder") + # Lets the Id:(Stack Overflow int) and id:(GraphFrames UUID) coexist + .config("spark.sql.caseSensitive", True).getOrCreate() +) + +print("Loading data for stats.meta.stackexchange.com ...") + + +# +# Load the Posts... +# +posts_df: DataFrame = ( + spark.read.format("xml") + .options(rowTag="row") + .options(rootTag="posts") + .load(f"{BASE_PATH}/Posts.xml") +) +print(f"\nTotal Posts: {posts_df.count():,}") + +# Remove the _ prefix from field names +posts_df = remove_prefix(posts_df) + +# Create a list of tags +posts_df = ( + posts_df.withColumn( + "ParsedTags", F.split(F.regexp_replace(F.col("Tags"), "^\\||\\|$", ""), "\\|") + ) + .drop("Tags") + .withColumnRenamed("ParsedTags", "Tags") +) + + +# +# Building blocks: separate the questions and answers +# + +# Do the questions look ok? Questions have NO parent ID and DO have a Title +questions_df: DataFrame = posts_df.filter(posts_df.ParentId.isNull()) +questions_df = questions_df.withColumn("Type", F.lit("Question")).cache() +print(f"\nTotal questions: {questions_df.count():,}\n") + +questions_df.select("ParentId", "Title", "Body").show(10) + +# Answers DO have a ParentId parent post and no Title +answers_df: DataFrame = posts_df.filter(posts_df.ParentId.isNotNull()) +answers_df = answers_df.withColumn("Type", F.lit("Answer")).cache() +print(f"\nTotal answers: {answers_df.count():,}\n") + +answers_df.select("ParentId", "Title", "Body").show(10) + + +# +# Load the PostLinks... +# + +post_links_df = ( + spark.read.format("xml") + .options(rowTag="row") + .options(rootTag="postlinks") + .load(f"{BASE_PATH}/PostLinks.xml") +) +print(f"Total PostLinks: {post_links_df.count():,}") + +# Remove the _ prefix from field names +post_links_df = ( + remove_prefix(post_links_df) + .withColumn( + "LinkType", + F.when(F.col("LinkTypeId") == 1, "Linked") + .when(F.col("LinkTypeId") == 3, "Duplicate") + .otherwise("Unknown"), + ) + .withColumn("Type", F.lit("PostLinks")) +) + + +# +# Load the PostHistory... +# + +post_history_df = ( + spark.read.format("xml") + .options(rowTag="row") + .options(rootTag="posthistory") + .load(f"{BASE_PATH}/PostHistory.xml") +) +print(f"Total PostHistory: {post_history_df.count():,}") + +# Remove the _ prefix from field names +post_history_df = remove_prefix(post_history_df).withColumn("Type", F.lit("PostHistory")) + + +# +# Load the Comments... +# + +comments_df = ( + spark.read.format("xml") + .options(rowTag="row") + .options(rootTag="comments") + .load(f"{BASE_PATH}/Comments.xml") +) +print(f"Total Comments: {comments_df.count():,}") + +# Remove the _ prefix from field names +comments_df = remove_prefix(comments_df).withColumn("Type", F.lit("Comment")) + + +# +# Load the Users... +# + +users_df = ( + spark.read.format("xml") + .options(rowTag="row") + .options(rootTag="users") + .load(f"{BASE_PATH}/Users.xml") +) +print(f"Total Users: {users_df.count():,}") + +# Remove the _ prefix from field names +users_df = remove_prefix(users_df).withColumn("Type", F.lit("User")) + + +# +# Load the Votes... +# + +votes_df = ( + spark.read.format("xml") + .options(rowTag="row") + .options(rootTag="votes") + .load(f"{BASE_PATH}/Votes.xml") +) +print(f"Total Votes: {votes_df.count():,}") + +# Remove the _ prefix from field names +votes_df = remove_prefix(votes_df).withColumn("Type", F.lit("Vote")) + +# Add a VoteType column +votes_df = votes_df.withColumn( + "VoteType", + F.when(F.col("VoteTypeId") == 2, "UpVote") + .when(F.col("VoteTypeId") == 3, "DownVote") + .when(F.col("VoteTypeId") == 4, "Favorite") + .when(F.col("VoteTypeId") == 5, "Close") + .when(F.col("VoteTypeId") == 6, "Reopen") + .when(F.col("VoteTypeId") == 7, "BountyStart") + .when(F.col("VoteTypeId") == 8, "BountyClose") + .when(F.col("VoteTypeId") == 9, "Deletion") + .when(F.col("VoteTypeId") == 10, "Undeletion") + .when(F.col("VoteTypeId") == 11, "Spam") + .when(F.col("VoteTypeId") == 12, "InformModerator") + .otherwise("Unknown"), +) + + +# +# Load the Tags... +# + +tags_df = ( + spark.read.format("xml") + .options(rowTag="row") + .options(rootTag="tags") + .load(f"{BASE_PATH}/Tags.xml") +) +print(f"Total Tags: {tags_df.count():,}") + +# Remove the _ prefix from field names +tags_df = remove_prefix(tags_df).withColumn("Type", F.lit("Tag")) + + +# +# Load the Badges... +# + +badges_df = ( + spark.read.format("xml") + .options(rowTag="row") + .options(rootTag="badges") + .load(f"{BASE_PATH}/Badges.xml") +) +print(f"Total Badges: {badges_df.count():,}\n") + +# Remove the _ prefix from field names +badges_df = remove_prefix(badges_df).withColumn("Type", F.lit("Badge")) + + +# +# Form the nodes from the UNION of posts, users, votes and their combined schemas +# + +all_cols: List[Tuple[str, T.StructField]] = list( + set( + list(zip(answers_df.columns, answers_df.schema)) + + list(zip(questions_df.columns, questions_df.schema)) + + list(zip(post_links_df.columns, post_links_df.schema)) + + list(zip(comments_df.columns, comments_df.schema)) + + list(zip(users_df.columns, users_df.schema)) + + list(zip(votes_df.columns, votes_df.schema)) + + list(zip(tags_df.columns, tags_df.schema)) + + list(zip(badges_df.columns, badges_df.schema)) + ) +) +all_column_names: List[str] = sorted([x[0] for x in all_cols]) + + +def add_missing_columns(df: DataFrame, all_cols: List[Tuple[str, T.StructField]]) -> DataFrame: + """Add any missing columns from any DataFrame among several we want to merge.""" + for col_name, schema_field in all_cols: + if col_name not in df.columns: + df = df.withColumn(col_name, F.lit(None).cast(schema_field.dataType)) + return df + + +# Now apply this function to each of your DataFrames to get a consistent schema +# posts_df = add_missing_columns(posts_df, all_cols).select(all_column_names) +questions_df = add_missing_columns(questions_df, all_cols).select(all_column_names) +answers_df = add_missing_columns(answers_df, all_cols).select(all_column_names) +post_links_df = add_missing_columns(post_links_df, all_cols).select(all_column_names) +users_df = add_missing_columns(users_df, all_cols).select(all_column_names) +votes_df = add_missing_columns(votes_df, all_cols).select(all_column_names) +tags_df = add_missing_columns(tags_df, all_cols).select(all_column_names) +badges_df = add_missing_columns(badges_df, all_cols).select(all_column_names) +assert ( + set(questions_df.columns) + == set(answers_df.columns) + == set(post_links_df.columns) + == set(users_df.columns) + == set(votes_df.columns) + == set(all_column_names) + == set(tags_df.columns) + == set(badges_df.columns) +) + +# Now union them together and remove duplicates +nodes_df: DataFrame = ( + questions_df.unionByName(answers_df) + .unionByName(post_links_df) + .unionByName(users_df) + .unionByName(votes_df) + .unionByName(tags_df) + .unionByName(badges_df) + .distinct() +) +print(f"Total distinct nodes: {nodes_df.count():,}") + +# Now add a unique ID field +nodes_df = nodes_df.withColumn("id", F.expr("uuid()")).select("id", *all_column_names) + +# Now create posts - combined questions and answers for things that can apply to them both +posts_df = questions_df.unionByName(answers_df).cache() + +# +# Store the nodes to disk, reload and cache +# + +NODES_PATH: str = f"{BASE_PATH}/Nodes.parquet" + +# Write to disk and load back again +nodes_df.write.mode("overwrite").parquet(NODES_PATH) +nodes_df = spark.read.parquet(NODES_PATH) + +nodes_df.select("id", "Type").groupBy("Type").count().orderBy(F.col("count").desc()).show() + +# +---------+------+ +# | Type| count| +# +---------+------+ +# | Badge|43,029| +# | Vote|42,593| +# | User|37,709| +# | Answer| 2,978| +# | Question| 2,025| +# |PostLinks| 1,274| +# | Tag| 143| +# +---------+------+ + +# Helps performance of GraphFrames' algorithms +nodes_df = nodes_df.cache() + +# Make sure we have the right columns and cached data +posts_df = nodes_df.filter(nodes_df.Type.isin("Question", "Answer")).cache() +questions_df = nodes_df.filter(nodes_df.Type == "Question").cache() +answers_df = nodes_df.filter(nodes_df.Type == "Answer").cache() +post_links_df = nodes_df.filter(nodes_df.Type == "PostLinks").cache() +users_df = nodes_df.filter(nodes_df.Type == "User").cache() +votes_df = nodes_df.filter(nodes_df.Type == "Vote").cache() +tags_df = nodes_df.filter(nodes_df.Type == "Tag").cache() +badges_df = nodes_df.filter(nodes_df.Type == "Badge").cache() + + +# +# Build the edges DataFrame: +# +# * [Vote]--CastFor-->[Post] +# * [User]--Asks-->[Question] +# * [User]--Posts-->[Answer] +# * [Post]--Answers-->[Question] +# * [Tag]--Tags-->[Post] +# * [User]--Earns-->[Badge] +# * [Post]--Links-->[Post] +# +# Remember: 'src', 'dst' and 'relationship' are standard edge fields in GraphFrames +# Remember: we must produce src/dst based on lowercase 'id' UUID, not 'Id' which is Stack Overflow's integer. +# + +# +# Create a [Vote]--CastFor-->[Post] edge... remember a Post is a Question or Answer +# + +src_vote_df: DataFrame = votes_df.select( + F.col("id").alias("src"), + F.col("Id").alias("VoteId"), + # Everything has all the fields - should build from base records but need UUIDs + F.col("PostId").alias("VotePostId"), +) +cast_for_edge_df: DataFrame = src_vote_df.join( + posts_df, on=src_vote_df.VotePostId == posts_df.Id, how="inner" +).select( + # 'src' comes from the votes' 'id' + "src", + # 'dst' comes from the posts' 'id' + F.col("id").alias("dst"), + # All edges have a 'relationship' field + F.lit("CastFor").alias("relationship"), +) +print(f"Total CastFor edges: {cast_for_edge_df.count():,}") +print(f"Percentage of linked votes: {cast_for_edge_df.count() / votes_df.count():.2%}\n") + +# +# Create a [User]--Asks-->[Question] edge +# + +questions_asked_df: DataFrame = questions_df.select( + F.col("OwnerUserId").alias("QuestionUserId"), + F.col("id").alias("dst"), + F.lit("Asks").alias("relationship"), +) +user_asks_edges_df: DataFrame = questions_asked_df.join( + users_df, on=questions_asked_df.QuestionUserId == users_df.Id, how="inner" +).select( + # 'src' comes from the users' 'id' + F.col("id").alias("src"), + # 'dst' comes from the posts' 'id' + "dst", + # All edges have a 'relationship' field + "relationship", +) +print(f"Total Asks edges: {user_asks_edges_df.count():,}") +print( + f"Percentage of asked questions linked to users: {user_asks_edges_df.count() / questions_df.count():.2%}\n" +) + +# +# Create a [User]--Posts-->[Answer] edge. +# + +user_answers_df: DataFrame = answers_df.select( + F.col("OwnerUserId").alias("AnswerUserId"), + F.col("id").alias("dst"), + F.lit("Posts").alias("relationship"), +) +user_answers_edges_df = user_answers_df.join( + users_df, on=user_answers_df.AnswerUserId == users_df.Id, how="inner" +).select( + # 'src' comes from the users' 'id' + F.col("id").alias("src"), + # 'dst' comes from the posts' 'id' + "dst", + # All edges have a 'relationship' field + "relationship", +) +print(f"Total User Answers edges: {user_answers_edges_df.count():,}") +print( + f"Percentage of answers linked to users: {user_answers_edges_df.count() / answers_df.count():.2%}\n" +) + +# +# Create a [Answer]--Answers-->[Question] edge +# + +src_answers_df: DataFrame = answers_df.select( + F.col("id").alias("src"), + F.col("Id").alias("AnswerId"), + F.col("ParentId").alias("AnswerParentId"), +) +question_answers_edges_df: DataFrame = src_answers_df.join( + posts_df, on=src_answers_df.AnswerParentId == questions_df.Id, how="inner" +).select( + # 'src' comes from the answers' 'id' + "src", + # 'dst' comes from the posts' 'id' + F.col("id").alias("dst"), + # All edges have a 'relationship' field + F.lit("Answers").alias("relationship"), +) +print(f"Total Posts Answers edges: {question_answers_edges_df.count():,}") +print( + f"Percentage of linked answers: {question_answers_edges_df.count() / answers_df.count():.2%}\n" +) + +# +# Create a [Tag]--Tags-->[Post] edge... remember a Post is a Question or Answer +# + +src_tags_df: DataFrame = posts_df.select( + F.col("id").alias("dst"), + # First remove leading/trailing < and >, then split on "><" + F.explode("Tags").alias("Tag"), +) +tags_edge_df: DataFrame = src_tags_df.join( + tags_df, on=src_tags_df.Tag == tags_df.TagName, how="inner" +).select( + # 'src' comes from the posts' 'id' + F.col("id").alias("src"), + # 'dst' comes from the tags' 'id' + "dst", + # All edges have a 'relationship' field + F.lit("Tags").alias("relationship"), +) +print(f"Total Tags edges: {tags_edge_df.count():,}") +print(f"Percentage of linked tags: {tags_edge_df.count() / posts_df.count():.2%}\n") + +# +# Create a [User]--Earns-->[Badge] edge +# + +earns_edges_df: DataFrame = badges_df.select( + F.col("UserId").alias("BadgeUserId"), + F.col("id").alias("dst"), + F.lit("Earns").alias("relationship"), +) +earns_edges_df = earns_edges_df.join( + users_df, on=earns_edges_df.BadgeUserId == users_df.Id, how="inner" +).select( + # 'src' comes from the users' 'id' + F.col("id").alias("src"), + # 'dst' comes from the badges' 'id' + "dst", + # All edges have a 'relationship' field + "relationship", +) +print(f"Total Earns edges: {earns_edges_df.count():,}") +print(f"Percentage of earned badges: {earns_edges_df.count() / badges_df.count():.2%}\n") + +# +# Create a [Post]--Links-->[Post] edge... remember a Post is a Question or Answer +# Also a [Post]--Duplicates-->[Post] edge... remember a Post is a Question or Answer +# + +trim_links_df: DataFrame = post_links_df.select( + F.col("PostId").alias("SrcPostId"), + F.col("RelatedPostId").alias("DstPostId"), + "LinkType", +) +links_src_edge_df: DataFrame = trim_links_df.join( + posts_df.drop("LinkType"), on=trim_links_df.SrcPostId == posts_df.Id, how="inner" +).select( + # 'dst' comes from the posts' 'id' + F.col("id").alias("src"), + "DstPostId", + "LinkType", +) +raw_links_edge_df = links_src_edge_df.join( + posts_df.drop("LinkType"), on=links_src_edge_df.DstPostId == posts_df.Id, how="inner" +).select( + "src", + # 'src' comes from the posts' 'id' + F.col("id").alias("dst"), + # All edges have a 'relationship' field + F.lit("Links").alias("relationship"), + "LinkType", +) + +duplicates_edge_df: DataFrame = ( + raw_links_edge_df.filter(F.col("LinkType") == "Duplicate") + .withColumn("relationship", F.lit("Duplicates")) + .select("src", "dst", "relationship") +) +print(f"Total Duplicates edges: {duplicates_edge_df.count():,}") +print(f"Percentage of duplicate posts: {duplicates_edge_df.count() / post_links_df.count():.2%}\n") + +linked_edge_df = ( + raw_links_edge_df.filter(F.col("LinkType") == "Linked") + .withColumn("relationship", F.lit("Links")) + .select("src", "dst", "relationship") +) +print(f"Total Links edges: {linked_edge_df.count():,}") +print(f"Percentage of linked posts: {linked_edge_df.count() / post_links_df.count():.2%}\n") + + +# +# Combine all the edges together into one relationships DataFrame +# + +relationships_df: DataFrame = ( + cast_for_edge_df.unionByName(user_asks_edges_df) + .unionByName(user_answers_edges_df) + .unionByName(question_answers_edges_df) + .unionByName(tags_edge_df) + .unionByName(earns_edges_df) + .unionByName(duplicates_edge_df) + .unionByName(linked_edge_df) +) +relationships_df.groupBy("relationship").count().orderBy(F.col("count").desc()).withColumn( + "count", F.format_number(F.col("count"), 0) +).show() + +# +------------+------+ +# |relationship| count| +# +------------+------+ +# | Earns|43,029| +# | CastFor|40,701| +# | Tags| 4,427| +# | Answers| 2,978| +# | Posts| 2,767| +# | Asks| 1,934| +# | Links| 1,180| +# | Duplicates| 88| +# +------------+------+ + +EDGES_PATH: str = f"{BASE_PATH}/Edges.parquet" + +# Write to disk and back again +relationships_df.write.mode("overwrite").parquet(EDGES_PATH) + +spark.stop() +print("Spark stopped.") diff --git a/python/graphframes/tutorials/utils.py b/python/graphframes/tutorials/utils.py new file mode 100644 index 000000000..54ef40f8b --- /dev/null +++ b/python/graphframes/tutorials/utils.py @@ -0,0 +1,122 @@ +from pyspark.sql import DataFrame +from graphframes import GraphFrame +from pyspark.sql import functions as F + + +def three_edge_count(paths: DataFrame) -> DataFrame: + """three_edge_count View the counts of the different types of 3-node graphlets in the graph. + + Parameters + ---------- + paths : pyspark.sql.DataFrame + A DataFrame of 3-paths in the graph. + + Returns + ------- + DataFrame + A DataFrame of the counts of the different types of 3-node graphlets in the graph. + """ + graphlet_type_df = paths.select( + F.col("a.Type").alias("A_Type"), + F.col("e1.relationship").alias("E_relationship"), + F.col("b.Type").alias("B_Type"), + F.col("e2.relationship").alias("E2_relationship"), + F.col("c.Type").alias("C_Type"), + F.col("e3.relationship").alias("E3_relationship"), + F.when(F.col("d").isNotNull(), F.col("d.Type")).alias("D_Type"), + ) + graphlet_count_df = ( + graphlet_type_df.groupby( + "A_Type", "E_relationship", "B_Type", "E2_relationship", "C_Type", "E3_relationship" + ) + .count() + .orderBy(F.col("count").desc()) + # Add a comma formatted column for display + .withColumn("count", F.format_number(F.col("count"), 0)) + ) + return graphlet_count_df + + +def four_edge_count(paths: DataFrame) -> DataFrame: + """four_edge_count View the counts of the different types of 4-node graphlets in the graph. + + Parameters + ---------- + paths : DataFrame + A DataFrame of 4-paths in the graph. + + Returns + ------- + DataFrame + A DataFrame of the counts of the different types of 4-node graphlets in the graph. + """ + + graphlet_type_df = paths.select( + F.col("a.Type").alias("A_Type"), + F.col("e1.relationship").alias("E_relationship"), + F.col("b.Type").alias("B_Type"), + F.col("e2.relationship").alias("E2_relationship"), + F.col("c.Type").alias("C_Type"), + F.col("e3.relationship").alias("E3_relationship"), + F.col("d.Type").alias("D_Type"), + F.col("e4.relationship").alias("E4_relationship"), + F.when(F.col("e").isNotNull(), F.col("e.Type")).alias("E_Type"), + ) + graphlet_count_df = ( + graphlet_type_df.groupby( + "A_Type", + "E_relationship", + "B_Type", + "E2_relationship", + "C_Type", + "E3_relationship", + "D_Type", + "E4_relationship", + ) + .count() + .orderBy(F.col("count").desc()) + # Add a comma formatted column for display + .withColumn("count", F.format_number(F.col("count"), 0)) + ) + return graphlet_count_df + + +def add_degree(g: GraphFrame) -> GraphFrame: + """add_degree compute the degree, adding it as a property of the nodes in the GraphFrame. + + Parameters + ---------- + g : GraphFrame + Any valid GraphFrame + + Returns + ------- + GraphFrame + Same GraphFrame with a 'degree' property added + """ + degree_vertices: DataFrame = g.vertices.join(g.degrees, on="id") + return GraphFrame(degree_vertices, g.edges) + + +def add_type_degree(g: GraphFrame) -> DataFrame: + """add_type_degree add a map property to the vertices with the degree by each type of relationship. + + Parameters + ---------- + g : GraphFrame + Any valid GraphFrame + + Returns + ------- + DataFrame - I am broke, next line is wrong + A GraphFrame with a map[type:degree] 'type_degree' field added to the vertices + """ + type_degree: DataFrame = ( + g.edges.select(F.col("src").alias("id"), "relationship") + .filter(F.col("id").isNotNull()) + .groupby("id", "relationship") + .count() + ) + type_degree = type_degree.withColumn("type_degree", F.create_map(type_degree.columns)) + type_degree = type_degree.select("src", "type_degree") + return g.vertices.join(type_degree, on="src") From 0a1fabad7ba44d8463b0b4b23cdb360181b583cb Mon Sep 17 00:00:00 2001 From: Russell Jurney Date: Mon, 17 Feb 2025 10:37:23 -0800 Subject: [PATCH 43/70] Created tutorials dependency group to minimize main bloat --- python/poetry.lock | 848 +++++++++++++++++++++++++++++++++++++++++- python/pyproject.toml | 5 + 2 files changed, 850 insertions(+), 3 deletions(-) diff --git a/python/poetry.lock b/python/poetry.lock index 0fb5fb139..a96131b72 100644 --- a/python/poetry.lock +++ b/python/poetry.lock @@ -47,13 +47,385 @@ d = ["aiohttp (>=3.10)"] jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] uvloop = ["uvloop (>=0.15.2)"] +[[package]] +name = "brotli" +version = "1.1.0" +description = "Python bindings for the Brotli compression library" +optional = false +python-versions = "*" +groups = ["tutorials"] +markers = "platform_python_implementation == \"CPython\"" +files = [ + {file = "Brotli-1.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e1140c64812cb9b06c922e77f1c26a75ec5e3f0fb2bf92cc8c58720dec276752"}, + {file = "Brotli-1.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c8fd5270e906eef71d4a8d19b7c6a43760c6abcfcc10c9101d14eb2357418de9"}, + {file = "Brotli-1.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1ae56aca0402a0f9a3431cddda62ad71666ca9d4dc3a10a142b9dce2e3c0cda3"}, + {file = "Brotli-1.1.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:43ce1b9935bfa1ede40028054d7f48b5469cd02733a365eec8a329ffd342915d"}, + {file = "Brotli-1.1.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:7c4855522edb2e6ae7fdb58e07c3ba9111e7621a8956f481c68d5d979c93032e"}, + {file = "Brotli-1.1.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:38025d9f30cf4634f8309c6874ef871b841eb3c347e90b0851f63d1ded5212da"}, + {file = "Brotli-1.1.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e6a904cb26bfefc2f0a6f240bdf5233be78cd2488900a2f846f3c3ac8489ab80"}, + {file = "Brotli-1.1.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:a37b8f0391212d29b3a91a799c8e4a2855e0576911cdfb2515487e30e322253d"}, + {file = "Brotli-1.1.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:e84799f09591700a4154154cab9787452925578841a94321d5ee8fb9a9a328f0"}, + {file = "Brotli-1.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f66b5337fa213f1da0d9000bc8dc0cb5b896b726eefd9c6046f699b169c41b9e"}, + {file = "Brotli-1.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5dab0844f2cf82be357a0eb11a9087f70c5430b2c241493fc122bb6f2bb0917c"}, + {file = "Brotli-1.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e4fe605b917c70283db7dfe5ada75e04561479075761a0b3866c081d035b01c1"}, + {file = "Brotli-1.1.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:1e9a65b5736232e7a7f91ff3d02277f11d339bf34099a56cdab6a8b3410a02b2"}, + {file = "Brotli-1.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:58d4b711689366d4a03ac7957ab8c28890415e267f9b6589969e74b6e42225ec"}, + {file = "Brotli-1.1.0-cp310-cp310-win32.whl", hash = "sha256:be36e3d172dc816333f33520154d708a2657ea63762ec16b62ece02ab5e4daf2"}, + {file = "Brotli-1.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:0c6244521dda65ea562d5a69b9a26120769b7a9fb3db2fe9545935ed6735b128"}, + {file = "Brotli-1.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a3daabb76a78f829cafc365531c972016e4aa8d5b4bf60660ad8ecee19df7ccc"}, + {file = "Brotli-1.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c8146669223164fc87a7e3de9f81e9423c67a79d6b3447994dfb9c95da16e2d6"}, + {file = "Brotli-1.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30924eb4c57903d5a7526b08ef4a584acc22ab1ffa085faceb521521d2de32dd"}, + {file = "Brotli-1.1.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ceb64bbc6eac5a140ca649003756940f8d6a7c444a68af170b3187623b43bebf"}, + {file = "Brotli-1.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a469274ad18dc0e4d316eefa616d1d0c2ff9da369af19fa6f3daa4f09671fd61"}, + {file = "Brotli-1.1.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:524f35912131cc2cabb00edfd8d573b07f2d9f21fa824bd3fb19725a9cf06327"}, + {file = "Brotli-1.1.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5b3cc074004d968722f51e550b41a27be656ec48f8afaeeb45ebf65b561481dd"}, + {file = "Brotli-1.1.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:19c116e796420b0cee3da1ccec3b764ed2952ccfcc298b55a10e5610ad7885f9"}, + {file = "Brotli-1.1.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:510b5b1bfbe20e1a7b3baf5fed9e9451873559a976c1a78eebaa3b86c57b4265"}, + {file = "Brotli-1.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a1fd8a29719ccce974d523580987b7f8229aeace506952fa9ce1d53a033873c8"}, + {file = "Brotli-1.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c247dd99d39e0338a604f8c2b3bc7061d5c2e9e2ac7ba9cc1be5a69cb6cd832f"}, + {file = "Brotli-1.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1b2c248cd517c222d89e74669a4adfa5577e06ab68771a529060cf5a156e9757"}, + {file = "Brotli-1.1.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:2a24c50840d89ded6c9a8fdc7b6ed3692ed4e86f1c4a4a938e1e92def92933e0"}, + {file = "Brotli-1.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f31859074d57b4639318523d6ffdca586ace54271a73ad23ad021acd807eb14b"}, + {file = "Brotli-1.1.0-cp311-cp311-win32.whl", hash = "sha256:39da8adedf6942d76dc3e46653e52df937a3c4d6d18fdc94a7c29d263b1f5b50"}, + {file = "Brotli-1.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:aac0411d20e345dc0920bdec5548e438e999ff68d77564d5e9463a7ca9d3e7b1"}, + {file = "Brotli-1.1.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:32d95b80260d79926f5fab3c41701dbb818fde1c9da590e77e571eefd14abe28"}, + {file = "Brotli-1.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b760c65308ff1e462f65d69c12e4ae085cff3b332d894637f6273a12a482d09f"}, + {file = "Brotli-1.1.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:316cc9b17edf613ac76b1f1f305d2a748f1b976b033b049a6ecdfd5612c70409"}, + {file = "Brotli-1.1.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:caf9ee9a5775f3111642d33b86237b05808dafcd6268faa492250e9b78046eb2"}, + {file = "Brotli-1.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:70051525001750221daa10907c77830bc889cb6d865cc0b813d9db7fefc21451"}, + {file = "Brotli-1.1.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7f4bf76817c14aa98cc6697ac02f3972cb8c3da93e9ef16b9c66573a68014f91"}, + {file = "Brotli-1.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d0c5516f0aed654134a2fc936325cc2e642f8a0e096d075209672eb321cff408"}, + {file = "Brotli-1.1.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6c3020404e0b5eefd7c9485ccf8393cfb75ec38ce75586e046573c9dc29967a0"}, + {file = "Brotli-1.1.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:4ed11165dd45ce798d99a136808a794a748d5dc38511303239d4e2363c0695dc"}, + {file = "Brotli-1.1.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:4093c631e96fdd49e0377a9c167bfd75b6d0bad2ace734c6eb20b348bc3ea180"}, + {file = "Brotli-1.1.0-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:7e4c4629ddad63006efa0ef968c8e4751c5868ff0b1c5c40f76524e894c50248"}, + {file = "Brotli-1.1.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:861bf317735688269936f755fa136a99d1ed526883859f86e41a5d43c61d8966"}, + {file = "Brotli-1.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:87a3044c3a35055527ac75e419dfa9f4f3667a1e887ee80360589eb8c90aabb9"}, + {file = "Brotli-1.1.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c5529b34c1c9d937168297f2c1fde7ebe9ebdd5e121297ff9c043bdb2ae3d6fb"}, + {file = "Brotli-1.1.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:ca63e1890ede90b2e4454f9a65135a4d387a4585ff8282bb72964fab893f2111"}, + {file = "Brotli-1.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e79e6520141d792237c70bcd7a3b122d00f2613769ae0cb61c52e89fd3443839"}, + {file = "Brotli-1.1.0-cp312-cp312-win32.whl", hash = "sha256:5f4d5ea15c9382135076d2fb28dde923352fe02951e66935a9efaac8f10e81b0"}, + {file = "Brotli-1.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:906bc3a79de8c4ae5b86d3d75a8b77e44404b0f4261714306e3ad248d8ab0951"}, + {file = "Brotli-1.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8bf32b98b75c13ec7cf774164172683d6e7891088f6316e54425fde1efc276d5"}, + {file = "Brotli-1.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7bc37c4d6b87fb1017ea28c9508b36bbcb0c3d18b4260fcdf08b200c74a6aee8"}, + {file = "Brotli-1.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c0ef38c7a7014ffac184db9e04debe495d317cc9c6fb10071f7fefd93100a4f"}, + {file = "Brotli-1.1.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91d7cc2a76b5567591d12c01f019dd7afce6ba8cba6571187e21e2fc418ae648"}, + {file = "Brotli-1.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a93dde851926f4f2678e704fadeb39e16c35d8baebd5252c9fd94ce8ce68c4a0"}, + {file = "Brotli-1.1.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f0db75f47be8b8abc8d9e31bc7aad0547ca26f24a54e6fd10231d623f183d089"}, + {file = "Brotli-1.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6967ced6730aed543b8673008b5a391c3b1076d834ca438bbd70635c73775368"}, + {file = "Brotli-1.1.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7eedaa5d036d9336c95915035fb57422054014ebdeb6f3b42eac809928e40d0c"}, + {file = "Brotli-1.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d487f5432bf35b60ed625d7e1b448e2dc855422e87469e3f450aa5552b0eb284"}, + {file = "Brotli-1.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:832436e59afb93e1836081a20f324cb185836c617659b07b129141a8426973c7"}, + {file = "Brotli-1.1.0-cp313-cp313-win32.whl", hash = "sha256:43395e90523f9c23a3d5bdf004733246fba087f2948f87ab28015f12359ca6a0"}, + {file = "Brotli-1.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:9011560a466d2eb3f5a6e4929cf4a09be405c64154e12df0dd72713f6500e32b"}, + {file = "Brotli-1.1.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:a090ca607cbb6a34b0391776f0cb48062081f5f60ddcce5d11838e67a01928d1"}, + {file = "Brotli-1.1.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2de9d02f5bda03d27ede52e8cfe7b865b066fa49258cbab568720aa5be80a47d"}, + {file = "Brotli-1.1.0-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2333e30a5e00fe0fe55903c8832e08ee9c3b1382aacf4db26664a16528d51b4b"}, + {file = "Brotli-1.1.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4d4a848d1837973bf0f4b5e54e3bec977d99be36a7895c61abb659301b02c112"}, + {file = "Brotli-1.1.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:fdc3ff3bfccdc6b9cc7c342c03aa2400683f0cb891d46e94b64a197910dc4064"}, + {file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:5eeb539606f18a0b232d4ba45adccde4125592f3f636a6182b4a8a436548b914"}, + {file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:fd5f17ff8f14003595ab414e45fce13d073e0762394f957182e69035c9f3d7c2"}, + {file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:069a121ac97412d1fe506da790b3e69f52254b9df4eb665cd42460c837193354"}, + {file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:e93dfc1a1165e385cc8239fab7c036fb2cd8093728cbd85097b284d7b99249a2"}, + {file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_2_aarch64.whl", hash = "sha256:aea440a510e14e818e67bfc4027880e2fb500c2ccb20ab21c7a7c8b5b4703d75"}, + {file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_2_i686.whl", hash = "sha256:6974f52a02321b36847cd19d1b8e381bf39939c21efd6ee2fc13a28b0d99348c"}, + {file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_2_ppc64le.whl", hash = "sha256:a7e53012d2853a07a4a79c00643832161a910674a893d296c9f1259859a289d2"}, + {file = "Brotli-1.1.0-cp36-cp36m-musllinux_1_2_x86_64.whl", hash = "sha256:d7702622a8b40c49bffb46e1e3ba2e81268d5c04a34f460978c6b5517a34dd52"}, + {file = "Brotli-1.1.0-cp36-cp36m-win32.whl", hash = "sha256:a599669fd7c47233438a56936988a2478685e74854088ef5293802123b5b2460"}, + {file = "Brotli-1.1.0-cp36-cp36m-win_amd64.whl", hash = "sha256:d143fd47fad1db3d7c27a1b1d66162e855b5d50a89666af46e1679c496e8e579"}, + {file = "Brotli-1.1.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:11d00ed0a83fa22d29bc6b64ef636c4552ebafcef57154b4ddd132f5638fbd1c"}, + {file = "Brotli-1.1.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f733d788519c7e3e71f0855c96618720f5d3d60c3cb829d8bbb722dddce37985"}, + {file = "Brotli-1.1.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:929811df5462e182b13920da56c6e0284af407d1de637d8e536c5cd00a7daf60"}, + {file = "Brotli-1.1.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0b63b949ff929fbc2d6d3ce0e924c9b93c9785d877a21a1b678877ffbbc4423a"}, + {file = "Brotli-1.1.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:d192f0f30804e55db0d0e0a35d83a9fead0e9a359a9ed0285dbacea60cc10a84"}, + {file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:f296c40e23065d0d6650c4aefe7470d2a25fffda489bcc3eb66083f3ac9f6643"}, + {file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:919e32f147ae93a09fe064d77d5ebf4e35502a8df75c29fb05788528e330fe74"}, + {file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:23032ae55523cc7bccb4f6a0bf368cd25ad9bcdcc1990b64a647e7bbcce9cb5b"}, + {file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:224e57f6eac61cc449f498cc5f0e1725ba2071a3d4f48d5d9dffba42db196438"}, + {file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:cb1dac1770878ade83f2ccdf7d25e494f05c9165f5246b46a621cc849341dc01"}, + {file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:3ee8a80d67a4334482d9712b8e83ca6b1d9bc7e351931252ebef5d8f7335a547"}, + {file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:5e55da2c8724191e5b557f8e18943b1b4839b8efc3ef60d65985bcf6f587dd38"}, + {file = "Brotli-1.1.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:d342778ef319e1026af243ed0a07c97acf3bad33b9f29e7ae6a1f68fd083e90c"}, + {file = "Brotli-1.1.0-cp37-cp37m-win32.whl", hash = "sha256:587ca6d3cef6e4e868102672d3bd9dc9698c309ba56d41c2b9c85bbb903cdb95"}, + {file = "Brotli-1.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:2954c1c23f81c2eaf0b0717d9380bd348578a94161a65b3a2afc62c86467dd68"}, + {file = "Brotli-1.1.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:efa8b278894b14d6da122a72fefcebc28445f2d3f880ac59d46c90f4c13be9a3"}, + {file = "Brotli-1.1.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:03d20af184290887bdea3f0f78c4f737d126c74dc2f3ccadf07e54ceca3bf208"}, + {file = "Brotli-1.1.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6172447e1b368dcbc458925e5ddaf9113477b0ed542df258d84fa28fc45ceea7"}, + {file = "Brotli-1.1.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a743e5a28af5f70f9c080380a5f908d4d21d40e8f0e0c8901604d15cfa9ba751"}, + {file = "Brotli-1.1.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0541e747cce78e24ea12d69176f6a7ddb690e62c425e01d31cc065e69ce55b48"}, + {file = "Brotli-1.1.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:cdbc1fc1bc0bff1cef838eafe581b55bfbffaed4ed0318b724d0b71d4d377619"}, + {file = "Brotli-1.1.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:890b5a14ce214389b2cc36ce82f3093f96f4cc730c1cffdbefff77a7c71f2a97"}, + {file = "Brotli-1.1.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:1ab4fbee0b2d9098c74f3057b2bc055a8bd92ccf02f65944a241b4349229185a"}, + {file = "Brotli-1.1.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:141bd4d93984070e097521ed07e2575b46f817d08f9fa42b16b9b5f27b5ac088"}, + {file = "Brotli-1.1.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:fce1473f3ccc4187f75b4690cfc922628aed4d3dd013d047f95a9b3919a86596"}, + {file = "Brotli-1.1.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:d2b35ca2c7f81d173d2fadc2f4f31e88cc5f7a39ae5b6db5513cf3383b0e0ec7"}, + {file = "Brotli-1.1.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:af6fa6817889314555aede9a919612b23739395ce767fe7fcbea9a80bf140fe5"}, + {file = "Brotli-1.1.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:2feb1d960f760a575dbc5ab3b1c00504b24caaf6986e2dc2b01c09c87866a943"}, + {file = "Brotli-1.1.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:4410f84b33374409552ac9b6903507cdb31cd30d2501fc5ca13d18f73548444a"}, + {file = "Brotli-1.1.0-cp38-cp38-win32.whl", hash = "sha256:db85ecf4e609a48f4b29055f1e144231b90edc90af7481aa731ba2d059226b1b"}, + {file = "Brotli-1.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:3d7954194c36e304e1523f55d7042c59dc53ec20dd4e9ea9d151f1b62b4415c0"}, + {file = "Brotli-1.1.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5fb2ce4b8045c78ebbc7b8f3c15062e435d47e7393cc57c25115cfd49883747a"}, + {file = "Brotli-1.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7905193081db9bfa73b1219140b3d315831cbff0d8941f22da695832f0dd188f"}, + {file = "Brotli-1.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a77def80806c421b4b0af06f45d65a136e7ac0bdca3c09d9e2ea4e515367c7e9"}, + {file = "Brotli-1.1.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8dadd1314583ec0bf2d1379f7008ad627cd6336625d6679cf2f8e67081b83acf"}, + {file = "Brotli-1.1.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:901032ff242d479a0efa956d853d16875d42157f98951c0230f69e69f9c09bac"}, + {file = "Brotli-1.1.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:22fc2a8549ffe699bfba2256ab2ed0421a7b8fadff114a3d201794e45a9ff578"}, + {file = "Brotli-1.1.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ae15b066e5ad21366600ebec29a7ccbc86812ed267e4b28e860b8ca16a2bc474"}, + {file = "Brotli-1.1.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:949f3b7c29912693cee0afcf09acd6ebc04c57af949d9bf77d6101ebb61e388c"}, + {file = "Brotli-1.1.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:89f4988c7203739d48c6f806f1e87a1d96e0806d44f0fba61dba81392c9e474d"}, + {file = "Brotli-1.1.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:de6551e370ef19f8de1807d0a9aa2cdfdce2e85ce88b122fe9f6b2b076837e59"}, + {file = "Brotli-1.1.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0737ddb3068957cf1b054899b0883830bb1fec522ec76b1098f9b6e0f02d9419"}, + {file = "Brotli-1.1.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:4f3607b129417e111e30637af1b56f24f7a49e64763253bbc275c75fa887d4b2"}, + {file = "Brotli-1.1.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:6c6e0c425f22c1c719c42670d561ad682f7bfeeef918edea971a79ac5252437f"}, + {file = "Brotli-1.1.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:494994f807ba0b92092a163a0a283961369a65f6cbe01e8891132b7a320e61eb"}, + {file = "Brotli-1.1.0-cp39-cp39-win32.whl", hash = "sha256:f0d8a7a6b5983c2496e364b969f0e526647a06b075d034f3297dc66f3b360c64"}, + {file = "Brotli-1.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:cdad5b9014d83ca68c25d2e9444e28e967ef16e80f6b436918c700c117a85467"}, + {file = "Brotli-1.1.0.tar.gz", hash = "sha256:81de08ac11bcb85841e440c13611c00b67d3bf82698314928d0b676362546724"}, +] + +[[package]] +name = "brotlicffi" +version = "1.1.0.0" +description = "Python CFFI bindings to the Brotli library" +optional = false +python-versions = ">=3.7" +groups = ["tutorials"] +markers = "platform_python_implementation == \"PyPy\"" +files = [ + {file = "brotlicffi-1.1.0.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9b7ae6bd1a3f0df532b6d67ff674099a96d22bc0948955cb338488c31bfb8851"}, + {file = "brotlicffi-1.1.0.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:19ffc919fa4fc6ace69286e0a23b3789b4219058313cf9b45625016bf7ff996b"}, + {file = "brotlicffi-1.1.0.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9feb210d932ffe7798ee62e6145d3a757eb6233aa9a4e7db78dd3690d7755814"}, + {file = "brotlicffi-1.1.0.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:84763dbdef5dd5c24b75597a77e1b30c66604725707565188ba54bab4f114820"}, + {file = "brotlicffi-1.1.0.0-cp37-abi3-win32.whl", hash = "sha256:1b12b50e07c3911e1efa3a8971543e7648100713d4e0971b13631cce22c587eb"}, + {file = "brotlicffi-1.1.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:994a4f0681bb6c6c3b0925530a1926b7a189d878e6e5e38fae8efa47c5d9c613"}, + {file = "brotlicffi-1.1.0.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:2e4aeb0bd2540cb91b069dbdd54d458da8c4334ceaf2d25df2f4af576d6766ca"}, + {file = "brotlicffi-1.1.0.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4b7b0033b0d37bb33009fb2fef73310e432e76f688af76c156b3594389d81391"}, + {file = "brotlicffi-1.1.0.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54a07bb2374a1eba8ebb52b6fafffa2afd3c4df85ddd38fcc0511f2bb387c2a8"}, + {file = "brotlicffi-1.1.0.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7901a7dc4b88f1c1475de59ae9be59799db1007b7d059817948d8e4f12e24e35"}, + {file = "brotlicffi-1.1.0.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ce01c7316aebc7fce59da734286148b1d1b9455f89cf2c8a4dfce7d41db55c2d"}, + {file = "brotlicffi-1.1.0.0-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:246f1d1a90279bb6069de3de8d75a8856e073b8ff0b09dcca18ccc14cec85979"}, + {file = "brotlicffi-1.1.0.0-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cc4bc5d82bc56ebd8b514fb8350cfac4627d6b0743382e46d033976a5f80fab6"}, + {file = "brotlicffi-1.1.0.0-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:37c26ecb14386a44b118ce36e546ce307f4810bc9598a6e6cb4f7fca725ae7e6"}, + {file = "brotlicffi-1.1.0.0-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ca72968ae4eaf6470498d5c2887073f7efe3b1e7d7ec8be11a06a79cc810e990"}, + {file = "brotlicffi-1.1.0.0-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:add0de5b9ad9e9aa293c3aa4e9deb2b61e99ad6c1634e01d01d98c03e6a354cc"}, + {file = "brotlicffi-1.1.0.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:9b6068e0f3769992d6b622a1cd2e7835eae3cf8d9da123d7f51ca9c1e9c333e5"}, + {file = "brotlicffi-1.1.0.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8557a8559509b61e65083f8782329188a250102372576093c88930c875a69838"}, + {file = "brotlicffi-1.1.0.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2a7ae37e5d79c5bdfb5b4b99f2715a6035e6c5bf538c3746abc8e26694f92f33"}, + {file = "brotlicffi-1.1.0.0-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:391151ec86bb1c683835980f4816272a87eaddc46bb91cbf44f62228b84d8cca"}, + {file = "brotlicffi-1.1.0.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:2f3711be9290f0453de8eed5275d93d286abe26b08ab4a35d7452caa1fef532f"}, + {file = "brotlicffi-1.1.0.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:1a807d760763e398bbf2c6394ae9da5815901aa93ee0a37bca5efe78d4ee3171"}, + {file = "brotlicffi-1.1.0.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fa8ca0623b26c94fccc3a1fdd895be1743b838f3917300506d04aa3346fd2a14"}, + {file = "brotlicffi-1.1.0.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3de0cf28a53a3238b252aca9fed1593e9d36c1d116748013339f0949bfc84112"}, + {file = "brotlicffi-1.1.0.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6be5ec0e88a4925c91f3dea2bb0013b3a2accda6f77238f76a34a1ea532a1cb0"}, + {file = "brotlicffi-1.1.0.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:d9eb71bb1085d996244439154387266fd23d6ad37161f6f52f1cd41dd95a3808"}, + {file = "brotlicffi-1.1.0.0.tar.gz", hash = "sha256:b77827a689905143f87915310b93b273ab17888fd43ef350d4832c4a71083c13"}, +] + +[package.dependencies] +cffi = ">=1.0.0" + +[[package]] +name = "certifi" +version = "2025.1.31" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.6" +groups = ["tutorials"] +files = [ + {file = "certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe"}, + {file = "certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651"}, +] + +[[package]] +name = "cffi" +version = "1.17.1" +description = "Foreign Function Interface for Python calling C code." +optional = false +python-versions = ">=3.8" +groups = ["tutorials"] +markers = "platform_python_implementation == \"PyPy\"" +files = [ + {file = "cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14"}, + {file = "cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be"}, + {file = "cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c"}, + {file = "cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15"}, + {file = "cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401"}, + {file = "cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b"}, + {file = "cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655"}, + {file = "cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0"}, + {file = "cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4"}, + {file = "cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93"}, + {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3"}, + {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8"}, + {file = "cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65"}, + {file = "cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903"}, + {file = "cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e"}, + {file = "cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd"}, + {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed"}, + {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9"}, + {file = "cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d"}, + {file = "cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a"}, + {file = "cffi-1.17.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1"}, + {file = "cffi-1.17.1-cp38-cp38-win32.whl", hash = "sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8"}, + {file = "cffi-1.17.1-cp38-cp38-win_amd64.whl", hash = "sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1"}, + {file = "cffi-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16"}, + {file = "cffi-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e"}, + {file = "cffi-1.17.1-cp39-cp39-win32.whl", hash = "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7"}, + {file = "cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662"}, + {file = "cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824"}, +] + +[package.dependencies] +pycparser = "*" + +[[package]] +name = "charset-normalizer" +version = "3.4.1" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +optional = false +python-versions = ">=3.7" +groups = ["tutorials"] +files = [ + {file = "charset_normalizer-3.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:91b36a978b5ae0ee86c394f5a54d6ef44db1de0815eb43de826d41d21e4af3de"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7461baadb4dc00fd9e0acbe254e3d7d2112e7f92ced2adc96e54ef6501c5f176"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e218488cd232553829be0664c2292d3af2eeeb94b32bea483cf79ac6a694e037"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:80ed5e856eb7f30115aaf94e4a08114ccc8813e6ed1b5efa74f9f82e8509858f"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b010a7a4fd316c3c484d482922d13044979e78d1861f0e0650423144c616a46a"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4532bff1b8421fd0a320463030c7520f56a79c9024a4e88f01c537316019005a"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d973f03c0cb71c5ed99037b870f2be986c3c05e63622c017ea9816881d2dd247"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a3bd0dcd373514dcec91c411ddb9632c0d7d92aed7093b8c3bbb6d69ca74408"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d9c3cdf5390dcd29aa8056d13e8e99526cda0305acc038b96b30352aff5ff2bb"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2bdfe3ac2e1bbe5b59a1a63721eb3b95fc9b6817ae4a46debbb4e11f6232428d"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:eab677309cdb30d047996b36d34caeda1dc91149e4fdca0b1a039b3f79d9a807"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-win32.whl", hash = "sha256:c0429126cf75e16c4f0ad00ee0eae4242dc652290f940152ca8c75c3a4b6ee8f"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:9f0b8b1c6d84c8034a44893aba5e767bf9c7a211e313a9605d9c617d7083829f"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8bfa33f4f2672964266e940dd22a195989ba31669bd84629f05fab3ef4e2d125"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28bf57629c75e810b6ae989f03c0828d64d6b26a5e205535585f96093e405ed1"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f08ff5e948271dc7e18a35641d2f11a4cd8dfd5634f55228b691e62b37125eb3"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:234ac59ea147c59ee4da87a0c0f098e9c8d169f4dc2a159ef720f1a61bbe27cd"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd4ec41f914fa74ad1b8304bbc634b3de73d2a0889bd32076342a573e0779e00"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eea6ee1db730b3483adf394ea72f808b6e18cf3cb6454b4d86e04fa8c4327a12"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c96836c97b1238e9c9e3fe90844c947d5afbf4f4c92762679acfe19927d81d77"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4d86f7aff21ee58f26dcf5ae81a9addbd914115cdebcbb2217e4f0ed8982e146"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:09b5e6733cbd160dcc09589227187e242a30a49ca5cefa5a7edd3f9d19ed53fd"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5777ee0881f9499ed0f71cc82cf873d9a0ca8af166dfa0af8ec4e675b7df48e6"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:237bdbe6159cff53b4f24f397d43c6336c6b0b42affbe857970cefbb620911c8"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-win32.whl", hash = "sha256:8417cb1f36cc0bc7eaba8ccb0e04d55f0ee52df06df3ad55259b9a323555fc8b"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7f50a1f8c450f3925cb367d011448c39239bb3eb4117c36a6d354794de4ce76"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f30bf9fd9be89ecb2360c7d94a711f00c09b976258846efe40db3d05828e8089"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:97f68b8d6831127e4787ad15e6757232e14e12060bec17091b85eb1486b91d8d"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7974a0b5ecd505609e3b19742b60cee7aa2aa2fb3151bc917e6e2646d7667dcf"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc54db6c8593ef7d4b2a331b58653356cf04f67c960f584edb7c3d8c97e8f39e"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:311f30128d7d333eebd7896965bfcfbd0065f1716ec92bd5638d7748eb6f936a"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:7d053096f67cd1241601111b698f5cad775f97ab25d81567d3f59219b5f1adbd"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:807f52c1f798eef6cf26beb819eeb8819b1622ddfeef9d0977a8502d4db6d534"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:dccbe65bd2f7f7ec22c4ff99ed56faa1e9f785482b9bbd7c717e26fd723a1d1e"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:2fb9bd477fdea8684f78791a6de97a953c51831ee2981f8e4f583ff3b9d9687e"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:01732659ba9b5b873fc117534143e4feefecf3b2078b0a6a2e925271bb6f4cfa"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-win32.whl", hash = "sha256:7a4f97a081603d2050bfaffdefa5b02a9ec823f8348a572e39032caa8404a487"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:7b1bef6280950ee6c177b326508f86cad7ad4dff12454483b51d8b7d673a2c5d"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ecddf25bee22fe4fe3737a399d0d177d72bc22be6913acfab364b40bce1ba83c"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c60ca7339acd497a55b0ea5d506b2a2612afb2826560416f6894e8b5770d4a9"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b7b2d86dd06bfc2ade3312a83a5c364c7ec2e3498f8734282c6c3d4b07b346b8"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd78cfcda14a1ef52584dbb008f7ac81c1328c0f58184bf9a84c49c605002da6"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e27f48bcd0957c6d4cb9d6fa6b61d192d0b13d5ef563e5f2ae35feafc0d179c"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01ad647cdd609225c5350561d084b42ddf732f4eeefe6e678765636791e78b9a"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:619a609aa74ae43d90ed2e89bdd784765de0a25ca761b93e196d938b8fd1dbbd"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:89149166622f4db9b4b6a449256291dc87a99ee53151c74cbd82a53c8c2f6ccd"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:7709f51f5f7c853f0fb938bcd3bc59cdfdc5203635ffd18bf354f6967ea0f824"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:345b0426edd4e18138d6528aed636de7a9ed169b4aaf9d61a8c19e39d26838ca"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0907f11d019260cdc3f94fbdb23ff9125f6b5d1039b76003b5b0ac9d6a6c9d5b"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-win32.whl", hash = "sha256:ea0d8d539afa5eb2728aa1932a988a9a7af94f18582ffae4bc10b3fbdad0626e"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:329ce159e82018d646c7ac45b01a430369d526569ec08516081727a20e9e4af4"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:b97e690a2118911e39b4042088092771b4ae3fc3aa86518f84b8cf6888dbdb41"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78baa6d91634dfb69ec52a463534bc0df05dbd546209b79a3880a34487f4b84f"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1a2bc9f351a75ef49d664206d51f8e5ede9da246602dc2d2726837620ea034b2"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75832c08354f595c760a804588b9357d34ec00ba1c940c15e31e96d902093770"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0af291f4fe114be0280cdd29d533696a77b5b49cfde5467176ecab32353395c4"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0167ddc8ab6508fe81860a57dd472b2ef4060e8d378f0cc555707126830f2537"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2a75d49014d118e4198bcee5ee0a6f25856b29b12dbf7cd012791f8a6cc5c496"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:363e2f92b0f0174b2f8238240a1a30142e3db7b957a5dd5689b0e75fb717cc78"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ab36c8eb7e454e34e60eb55ca5d241a5d18b2c6244f6827a30e451c42410b5f7"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:4c0907b1928a36d5a998d72d64d8eaa7244989f7aaaf947500d3a800c83a3fd6"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:04432ad9479fa40ec0f387795ddad4437a2b50417c69fa275e212933519ff294"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-win32.whl", hash = "sha256:3bed14e9c89dcb10e8f3a29f9ccac4955aebe93c71ae803af79265c9ca5644c5"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:49402233c892a461407c512a19435d1ce275543138294f7ef013f0b63d5d3765"}, + {file = "charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85"}, + {file = "charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3"}, +] + [[package]] name = "click" version = "8.1.8" description = "Composable command line interface toolkit" optional = false python-versions = ">=3.7" -groups = ["dev"] +groups = ["dev", "tutorials"] files = [ {file = "click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2"}, {file = "click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a"}, @@ -68,7 +440,7 @@ 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 = ["dev"] +groups = ["dev", "tutorials"] markers = "platform_system == \"Windows\"" files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, @@ -92,6 +464,77 @@ mccabe = ">=0.7.0,<0.8.0" pycodestyle = ">=2.12.0,<2.13.0" pyflakes = ">=3.2.0,<3.3.0" +[[package]] +name = "idna" +version = "3.10" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.6" +groups = ["tutorials"] +files = [ + {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, + {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, +] + +[package.extras] +all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] + +[[package]] +name = "inflate64" +version = "1.0.1" +description = "deflate64 compression/decompression library" +optional = false +python-versions = ">=3.9" +groups = ["tutorials"] +files = [ + {file = "inflate64-1.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:5122a188995e47a735ab969edc9129d42bbd97b993df5a3f0819b87205ce81b4"}, + {file = "inflate64-1.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:975ed694c680e46a5c0bb872380a9c9da271a91f9c0646561c58e8f3714347d4"}, + {file = "inflate64-1.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8bcaf445d9cda5f7358e0c2b78144641560f8ce9e3e4351099754c49d26a34e8"}, + {file = "inflate64-1.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:daede09baba24117279109b30fdf935195e91957e31b995b86f8dd01711376ee"}, + {file = "inflate64-1.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0df40eaaba4fb8379d5c4fa5f56cc24741c4f1a91d4aef66438207473351ceaa"}, + {file = "inflate64-1.0.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:ef90855ff63d53c8fd3bfbf85b5280b22f82b9ab2e21a7eee45b8a19d9866c42"}, + {file = "inflate64-1.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:5daa4566c0b009c9ab8a6bf18ce407d14f5dbbb0d3068f3a43af939a17e117a7"}, + {file = "inflate64-1.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:d58a360b59685561a8feacee743479a9d7cc17c8d210aa1f2ae221f2513973cb"}, + {file = "inflate64-1.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31198c5f156806cee05b69b149074042b7b7d39274ff4c259b898e617294ac17"}, + {file = "inflate64-1.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4ab693bb1cd92573a997f8fe7b90a2ec1e17a507884598f5640656257b95ef49"}, + {file = "inflate64-1.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:95b6a60e305e6e759e37d6c36691fcb87678922c56b3ddc2df06cd56e04f41f6"}, + {file = "inflate64-1.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:711ef889bdb3b3b296881d1e49830a3a896938fba7033c4287f1aed9b9a20111"}, + {file = "inflate64-1.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d3178495970ecb5c6a32167a8b57fdeef3bf4e2843eaf8f2d8f816f523741e36"}, + {file = "inflate64-1.0.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e8373b7feedf10236eb56d21598a19a3eb51077c3702d0ce3456b827374025e1"}, + {file = "inflate64-1.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:cf026d5c885f2d2bbf233e9a0c8c6d046ec727e2467024ffe0ac76b5be308258"}, + {file = "inflate64-1.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:3aa7489241e6c6f6d34b9561efdf06031c35305b864267a5b8f406abcd3e85c5"}, + {file = "inflate64-1.0.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:b81b3d373190ecd82901f42afd90b7127e9bdef341032a94db381c750ed3ddb2"}, + {file = "inflate64-1.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:dbfddc5dac975227c20997f0ac515917a15421767c6bff0c209ac6ff9d7b17cc"}, + {file = "inflate64-1.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2adeabe79cc2f90bca832673520c8cbad7370f86353e151293add7ca529bed34"}, + {file = "inflate64-1.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b235c97a05dbe2f92f0f057426e4d05a449e1fccf8e9aa88075ea9c6a06a182"}, + {file = "inflate64-1.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:19b74e30734dca5f1c83ca07074e1f25bf7b63f4a5ee7e074d9a4cb05af65cd5"}, + {file = "inflate64-1.0.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b298feb85204b5ef148ccf807744c836fffed7c1ed3ec8bc9b4e323a03163291"}, + {file = "inflate64-1.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8a4c75241bc442267f79b8242135f2ded29405662c44b9353d34fbd4fa6e56b3"}, + {file = "inflate64-1.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:7b210392f0830ab27371e36478592f47757f5ea6c09ddb96e2125847b309eb5e"}, + {file = "inflate64-1.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8dd58aa1adc4f98bf9b52baffa8f2ddf589e071a90db2f2bec9024328d4608cf"}, + {file = "inflate64-1.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c108be2b87e88c966570f84f839eb37f489b45dc3fa3046dc228327af6e921bb"}, + {file = "inflate64-1.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:63971c6b096c0d533c0e38b4257f5a7748501a8bc04d00cf239bd06467888703"}, + {file = "inflate64-1.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2d0077edb6b1cabfa2223b71a4a725e5755148f551a7a396c7d5698e45fb8828"}, + {file = "inflate64-1.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f05b5f2a6f1bf2f70e9c20d997261711cbc1ae477379662b05b36911da60a67"}, + {file = "inflate64-1.0.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:5f3c7402165f7e15789caa0787e5a349465d9a454105d0c3a0ccf2e9cdfb8117"}, + {file = "inflate64-1.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:39bced168822e4bf2f545d1b6dbeded6db01c32629d9e4549ef2cd1604a12e1b"}, + {file = "inflate64-1.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:70bb6a22d300d8ca25c26bc60afb5662c5a96d97a801962874d0461568512789"}, + {file = "inflate64-1.0.1-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:f3d5ea758358a1cc50f9e8e41de2134e9b5c5ca8bbcd88d1cd135d0e953d0fa8"}, + {file = "inflate64-1.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8fa102c834314c3d7edbf249d1be0bce5d12a9e122228a7ac3f861ee82c3dc5c"}, + {file = "inflate64-1.0.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c2ae56a34e6cc2a712418ac82332e5d550ef8599e0ffb64c19b86d63a7df0c5"}, + {file = "inflate64-1.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:9808ae50b5db661770992566e51e648cac286c32bd80892b151e7b1eca81afe8"}, + {file = "inflate64-1.0.1-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:04b2788c6a26e1e525f53cc3d8c58782d41f18bef8d2a34a3d58beaaf0bfdd3b"}, + {file = "inflate64-1.0.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67fd5b1f9e433b0abab8cb91f4da94d16223a5241008268a57f4729fdbfc4dbc"}, + {file = "inflate64-1.0.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c6f3b00c17ae365e82fc3d48ff9a7a566820a6c8c55b4e16c6cfbcbd46505a72"}, + {file = "inflate64-1.0.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:91c0c1d41c1655fb0189630baaa894a3b778d77062bb90ca11db878422948395"}, + {file = "inflate64-1.0.1.tar.gz", hash = "sha256:3b1c83c22651b5942b35829df526e89602e494192bf021e0d7d0b600e76c429d"}, +] + +[package.extras] +check = ["check-manifest", "flake8", "flake8-black", "flake8-deprecated", "flake8-isort", "mypy (>=1.10.0)", "mypy_extensions (>=0.4.1)", "pygments", "readme-renderer", "twine"] +docs = ["docutils", "sphinx (>=5.0)"] +test = ["pytest"] + [[package]] name = "isort" version = "6.0.0" @@ -120,6 +563,23 @@ files = [ {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, ] +[[package]] +name = "multivolumefile" +version = "0.2.3" +description = "multi volume file wrapper library" +optional = false +python-versions = ">=3.6" +groups = ["tutorials"] +files = [ + {file = "multivolumefile-0.2.3-py3-none-any.whl", hash = "sha256:237f4353b60af1703087cf7725755a1f6fcaeeea48421e1896940cd1c920d678"}, + {file = "multivolumefile-0.2.3.tar.gz", hash = "sha256:a0648d0aafbc96e59198d5c17e9acad7eb531abea51035d08ce8060dcad709d6"}, +] + +[package.extras] +check = ["check-manifest", "flake8", "flake8-black", "isort (>=5.0.3)", "pygments", "readme-renderer", "twine"] +test = ["coverage[toml] (>=5.2)", "coveralls (>=2.1.1)", "hypothesis", "pyannotate", "pytest", "pytest-cov"] +type = ["mypy", "mypy-extensions"] + [[package]] name = "mypy-extensions" version = "1.0.0" @@ -241,6 +701,31 @@ 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 = "psutil" +version = "7.0.0" +description = "Cross-platform lib for process and system monitoring in Python. NOTE: the syntax of this script MUST be kept compatible with Python 2.7." +optional = false +python-versions = ">=3.6" +groups = ["tutorials"] +markers = "sys_platform != \"cygwin\"" +files = [ + {file = "psutil-7.0.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:101d71dc322e3cffd7cea0650b09b3d08b8e7c4109dd6809fe452dfd00e58b25"}, + {file = "psutil-7.0.0-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:39db632f6bb862eeccf56660871433e111b6ea58f2caea825571951d4b6aa3da"}, + {file = "psutil-7.0.0-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fcee592b4c6f146991ca55919ea3d1f8926497a713ed7faaf8225e174581e91"}, + {file = "psutil-7.0.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b1388a4f6875d7e2aff5c4ca1cc16c545ed41dd8bb596cefea80111db353a34"}, + {file = "psutil-7.0.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5f098451abc2828f7dc6b58d44b532b22f2088f4999a937557b603ce72b1993"}, + {file = "psutil-7.0.0-cp36-cp36m-win32.whl", hash = "sha256:84df4eb63e16849689f76b1ffcb36db7b8de703d1bc1fe41773db487621b6c17"}, + {file = "psutil-7.0.0-cp36-cp36m-win_amd64.whl", hash = "sha256:1e744154a6580bc968a0195fd25e80432d3afec619daf145b9e5ba16cc1d688e"}, + {file = "psutil-7.0.0-cp37-abi3-win32.whl", hash = "sha256:ba3fcef7523064a6c9da440fc4d6bd07da93ac726b5733c29027d7dc95b39d99"}, + {file = "psutil-7.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:4cf3d4eb1aa9b348dec30105c55cd9b7d4629285735a102beb4441e38db90553"}, + {file = "psutil-7.0.0.tar.gz", hash = "sha256:7be9c3eba38beccb6495ea33afd982a44074b78f28c434a1f51cc07fd315c456"}, +] + +[package.extras] +dev = ["abi3audit", "black (==24.10.0)", "check-manifest", "coverage", "packaging", "pylint", "pyperf", "pypinfo", "pytest", "pytest-cov", "pytest-xdist", "requests", "rstcheck", "ruff", "setuptools", "sphinx", "sphinx_rtd_theme", "toml-sort", "twine", "virtualenv", "vulture", "wheel"] +test = ["pytest", "pytest-xdist", "setuptools"] + [[package]] name = "py4j" version = "0.10.9.7" @@ -253,6 +738,92 @@ files = [ {file = "py4j-0.10.9.7.tar.gz", hash = "sha256:0b6e5315bb3ada5cf62ac651d107bb2ebc02def3dee9d9548e3baac644ea8dbb"}, ] +[[package]] +name = "py7zr" +version = "0.22.0" +description = "Pure python 7-zip library" +optional = false +python-versions = ">=3.8" +groups = ["tutorials"] +files = [ + {file = "py7zr-0.22.0-py3-none-any.whl", hash = "sha256:993b951b313500697d71113da2681386589b7b74f12e48ba13cc12beca79d078"}, + {file = "py7zr-0.22.0.tar.gz", hash = "sha256:c6c7aea5913535184003b73938490f9a4d8418598e533f9ca991d3b8e45a139e"}, +] + +[package.dependencies] +brotli = {version = ">=1.1.0", markers = "platform_python_implementation == \"CPython\""} +brotlicffi = {version = ">=1.1.0.0", markers = "platform_python_implementation == \"PyPy\""} +inflate64 = ">=1.0.0,<1.1.0" +multivolumefile = ">=0.2.3" +psutil = {version = "*", markers = "sys_platform != \"cygwin\""} +pybcj = ">=1.0.0,<1.1.0" +pycryptodomex = ">=3.16.0" +pyppmd = ">=1.1.0,<1.2.0" +pyzstd = ">=0.15.9" +texttable = "*" + +[package.extras] +check = ["black (>=23.1.0)", "check-manifest", "flake8 (<8)", "flake8-black (>=0.3.6)", "flake8-deprecated", "flake8-isort", "isort (>=5.0.3)", "lxml", "mypy (>=0.940)", "mypy-extensions (>=0.4.1)", "pygments", "readme-renderer", "twine", "types-psutil"] +debug = ["pytest", "pytest-leaks", "pytest-profiling"] +docs = ["docutils", "sphinx (>=5.0)", "sphinx-a4doc", "sphinx-py3doc-enhanced-theme"] +test = ["coverage[toml] (>=5.2)", "coveralls (>=2.1.1)", "py-cpuinfo", "pytest", "pytest-benchmark", "pytest-cov", "pytest-remotedata", "pytest-timeout"] +test-compat = ["libarchive-c"] + +[[package]] +name = "pybcj" +version = "1.0.3" +description = "bcj filter library" +optional = false +python-versions = ">=3.9" +groups = ["tutorials"] +files = [ + {file = "pybcj-1.0.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:0bd8afeacf9173af091a08783aa9111500f5619ce0ae486bffb5ee4d08a331b4"}, + {file = "pybcj-1.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fc81d3c941485e7d3c2812834ca005849fe91a624977ed5227658cf952d19696"}, + {file = "pybcj-1.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f01b75621452578ccd48a79819bc95ddac41535e16aa163ea1d86b14258afa00"}, + {file = "pybcj-1.0.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e08431845702173d50d66cbbd169969d7b7cf67992f5fb7bc27a8c67e19d3d1f"}, + {file = "pybcj-1.0.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:476f3c815b85e563d13238c4310b9cb47aefd0c51ac1b33312e41fcd079ea94f"}, + {file = "pybcj-1.0.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:97bfd712bfce0d58099a02acc05b15b1d1aa3e6edf4dd8e018f43349182ffa3f"}, + {file = "pybcj-1.0.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4d1374806cde777bc6e371f79c7f3acfb2b0906a418e04cf5331866a321633c3"}, + {file = "pybcj-1.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:9245039e0fc87921f702133c019722e333934e61f1c90408f16618d585ff88ec"}, + {file = "pybcj-1.0.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ae30aa62deff1ba40e4f13ef6964cf083ece541dbfb3ec3731c1fc58cc218b7d"}, + {file = "pybcj-1.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6639f5443bc696a981a502c37e1393398a7182d61820eb39ee6d122076b6ad8c"}, + {file = "pybcj-1.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4502c5afa2a41e569b94527bbb46185ee1a378a4fb3e9d7806ad10e892ecdf58"}, + {file = "pybcj-1.0.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c4ff48aaadd8fd91ac02557eec225ce7c1a3b627a6832d6cb723469891b3b242"}, + {file = "pybcj-1.0.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:62668bd0a1aedaa3b779615cf129d9469fd709ab8d944aa07aad68dc189de349"}, + {file = "pybcj-1.0.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:8af60d5eeed32fd1a9f6a2a11eef47cb7ebd80fe9853e709a2c1d9e29108cdf2"}, + {file = "pybcj-1.0.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:68e1bd1b0836e216cce3d9a33795501dfc956c61ff52768737e26286e65a3771"}, + {file = "pybcj-1.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:05738d44a987422e21f4ee15023a8c4f38a5509fdf6e6f6dfaaf43ca05cef7db"}, + {file = "pybcj-1.0.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:c68a3fe847f22a8393fe71b1b16450b6b9e8ef36faa36d0c03759f58740f6eff"}, + {file = "pybcj-1.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:17f610ede3a766c0ff1869a4dd7750db78d39e4bfc9997f6bef050fe794c051b"}, + {file = "pybcj-1.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:15f776925a4d6f69b344cde9035fc8f1fd02f1f2a4ccb76f4047406c0ea4241d"}, + {file = "pybcj-1.0.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bdda28e0a20214c7f0e7de9e260122b9197106231249bf07a5ca5b84a5d38a1"}, + {file = "pybcj-1.0.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:764cba20166fcd9ff580f4d877f17807be057da7d1234caaf54fd5fd5c591387"}, + {file = "pybcj-1.0.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:97cf7f788560c3283a8afe3de585abb849bb1338d007e53fb6441d6ccd202e0a"}, + {file = "pybcj-1.0.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:26d201f773d17d5e8a88785f00fa73a6647e080d933e75ddeb33da7f0baff657"}, + {file = "pybcj-1.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:990047ac176317d42e7059b3cd357ff7c7201f3e3f08b35d083b2004d066cd39"}, + {file = "pybcj-1.0.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:3bbbf22687c9f6c57cc9b605a3a60937230843ff1b5560e2a42133fd4dd5dc73"}, + {file = "pybcj-1.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e0a75d5ec3fa40af865f93f29e613d93fb67dc016fc60e64a4b3a4621076fecd"}, + {file = "pybcj-1.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:631bcdea0d47ae562f118f8404fb6ef5813eb2dcfbcc53c7b9ac6bc5d4c2ef32"}, + {file = "pybcj-1.0.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:75c9430a10e69fbea336668944c0f4a9979e0bb3ab5de820315025c157baa2ae"}, + {file = "pybcj-1.0.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5221652a9c656f6b27fda389cc4888354a287d3e0f6ea6d5b70718b6d9ec110d"}, + {file = "pybcj-1.0.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:f6a6c3a776aa9b579c51768d2c727d3912cd8e1c2add61898dc6794b269e7ab3"}, + {file = "pybcj-1.0.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:cb50276bd804f58690571c13e2e6eb26eca6c4a39a611591e2202136dca1b7a5"}, + {file = "pybcj-1.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:623a4eef080f5cb0405ce19f90fa9824e2477f4a85d8b888e613cf7f146b84d1"}, + {file = "pybcj-1.0.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:47d2a0f33dfd55dfa961502922d2b0f090857585b321f838f1c2510de4e66a9a"}, + {file = "pybcj-1.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:cf8ac15785412aa6924818fb86e250ae15e8238b7db7d410e28d3ae0743cdbd3"}, + {file = "pybcj-1.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de02d2933fef5b26d845d2e002996c5e22c710af5b5dfc930285dff09db885cf"}, + {file = "pybcj-1.0.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40a0f542dba6d079d702c1c129cc8cdc0f20bf2c5cb45defba8d5ac8e2d691a1"}, + {file = "pybcj-1.0.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ace508285fd4788845a208dd00f1c7af8e68dd222cf7797ae525562a2eb22bab"}, + {file = "pybcj-1.0.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:6da2b0c120a415fa5620b76110bab487de20f8a108756499fd4df9c92fc10098"}, + {file = "pybcj-1.0.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9c6347f1e2c78cf2584fddebe6fb9dc036b75020887facec1bab149fd6056c6"}, + {file = "pybcj-1.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:be309c0fbf06b1e8cd1c40b24dd621271b5fb5d9fe7a0becb40ed64ac92ff50b"}, + {file = "pybcj-1.0.3.tar.gz", hash = "sha256:b8873637f0be00ceaa372d0fb81693604b4bbc8decdb2b1ae5f9b84d196788d9"}, +] + +[package.extras] +check = ["check-manifest", "flake8 (<5)", "flake8-black", "flake8-colors", "flake8-isort", "flake8-pyi", "flake8-typing-imports", "mypy (>=1.10.0)", "pygments", "readme-renderer"] +test = ["coverage[toml] (>=5.2)", "hypothesis", "pytest (>=6.0)", "pytest-cov"] + [[package]] name = "pycodestyle" version = "2.12.1" @@ -265,6 +836,61 @@ files = [ {file = "pycodestyle-2.12.1.tar.gz", hash = "sha256:6838eae08bbce4f6accd5d5572075c63626a15ee3e6f842df996bf62f6d73521"}, ] +[[package]] +name = "pycparser" +version = "2.22" +description = "C parser in Python" +optional = false +python-versions = ">=3.8" +groups = ["tutorials"] +markers = "platform_python_implementation == \"PyPy\"" +files = [ + {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, + {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, +] + +[[package]] +name = "pycryptodomex" +version = "3.21.0" +description = "Cryptographic library for Python" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" +groups = ["tutorials"] +files = [ + {file = "pycryptodomex-3.21.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:dbeb84a399373df84a69e0919c1d733b89e049752426041deeb30d68e9867822"}, + {file = "pycryptodomex-3.21.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:a192fb46c95489beba9c3f002ed7d93979423d1b2a53eab8771dbb1339eb3ddd"}, + {file = "pycryptodomex-3.21.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:1233443f19d278c72c4daae749872a4af3787a813e05c3561c73ab0c153c7b0f"}, + {file = "pycryptodomex-3.21.0-cp27-cp27m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bbb07f88e277162b8bfca7134b34f18b400d84eac7375ce73117f865e3c80d4c"}, + {file = "pycryptodomex-3.21.0-cp27-cp27m-musllinux_1_1_aarch64.whl", hash = "sha256:e859e53d983b7fe18cb8f1b0e29d991a5c93be2c8dd25db7db1fe3bd3617f6f9"}, + {file = "pycryptodomex-3.21.0-cp27-cp27m-win32.whl", hash = "sha256:ef046b2e6c425647971b51424f0f88d8a2e0a2a63d3531817968c42078895c00"}, + {file = "pycryptodomex-3.21.0-cp27-cp27m-win_amd64.whl", hash = "sha256:da76ebf6650323eae7236b54b1b1f0e57c16483be6e3c1ebf901d4ada47563b6"}, + {file = "pycryptodomex-3.21.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:c07e64867a54f7e93186a55bec08a18b7302e7bee1b02fd84c6089ec215e723a"}, + {file = "pycryptodomex-3.21.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:56435c7124dd0ce0c8bdd99c52e5d183a0ca7fdcd06c5d5509423843f487dd0b"}, + {file = "pycryptodomex-3.21.0-cp27-cp27mu-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:65d275e3f866cf6fe891411be9c1454fb58809ccc5de6d3770654c47197acd65"}, + {file = "pycryptodomex-3.21.0-cp27-cp27mu-musllinux_1_1_aarch64.whl", hash = "sha256:5241bdb53bcf32a9568770a6584774b1b8109342bd033398e4ff2da052123832"}, + {file = "pycryptodomex-3.21.0-cp36-abi3-macosx_10_9_universal2.whl", hash = "sha256:34325b84c8b380675fd2320d0649cdcbc9cf1e0d1526edbe8fce43ed858cdc7e"}, + {file = "pycryptodomex-3.21.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:103c133d6cd832ae7266feb0a65b69e3a5e4dbbd6f3a3ae3211a557fd653f516"}, + {file = "pycryptodomex-3.21.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77ac2ea80bcb4b4e1c6a596734c775a1615d23e31794967416afc14852a639d3"}, + {file = "pycryptodomex-3.21.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9aa0cf13a1a1128b3e964dc667e5fe5c6235f7d7cfb0277213f0e2a783837cc2"}, + {file = "pycryptodomex-3.21.0-cp36-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:46eb1f0c8d309da63a2064c28de54e5e614ad17b7e2f88df0faef58ce192fc7b"}, + {file = "pycryptodomex-3.21.0-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:cc7e111e66c274b0df5f4efa679eb31e23c7545d702333dfd2df10ab02c2a2ce"}, + {file = "pycryptodomex-3.21.0-cp36-abi3-musllinux_1_2_i686.whl", hash = "sha256:770d630a5c46605ec83393feaa73a9635a60e55b112e1fb0c3cea84c2897aa0a"}, + {file = "pycryptodomex-3.21.0-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:52e23a0a6e61691134aa8c8beba89de420602541afaae70f66e16060fdcd677e"}, + {file = "pycryptodomex-3.21.0-cp36-abi3-win32.whl", hash = "sha256:a3d77919e6ff56d89aada1bd009b727b874d464cb0e2e3f00a49f7d2e709d76e"}, + {file = "pycryptodomex-3.21.0-cp36-abi3-win_amd64.whl", hash = "sha256:b0e9765f93fe4890f39875e6c90c96cb341767833cfa767f41b490b506fa9ec0"}, + {file = "pycryptodomex-3.21.0-pp27-pypy_73-manylinux2010_x86_64.whl", hash = "sha256:feaecdce4e5c0045e7a287de0c4351284391fe170729aa9182f6bd967631b3a8"}, + {file = "pycryptodomex-3.21.0-pp27-pypy_73-win32.whl", hash = "sha256:365aa5a66d52fd1f9e0530ea97f392c48c409c2f01ff8b9a39c73ed6f527d36c"}, + {file = "pycryptodomex-3.21.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:3efddfc50ac0ca143364042324046800c126a1d63816d532f2e19e6f2d8c0c31"}, + {file = "pycryptodomex-3.21.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0df2608682db8279a9ebbaf05a72f62a321433522ed0e499bc486a6889b96bf3"}, + {file = "pycryptodomex-3.21.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5823d03e904ea3e53aebd6799d6b8ec63b7675b5d2f4a4bd5e3adcb512d03b37"}, + {file = "pycryptodomex-3.21.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:27e84eeff24250ffec32722334749ac2a57a5fd60332cd6a0680090e7c42877e"}, + {file = "pycryptodomex-3.21.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:8ef436cdeea794015263853311f84c1ff0341b98fc7908e8a70595a68cefd971"}, + {file = "pycryptodomex-3.21.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a1058e6dfe827f4209c5cae466e67610bcd0d66f2f037465daa2a29d92d952b"}, + {file = "pycryptodomex-3.21.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9ba09a5b407cbb3bcb325221e346a140605714b5e880741dc9a1e9ecf1688d42"}, + {file = "pycryptodomex-3.21.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:8a9d8342cf22b74a746e3c6c9453cb0cfbb55943410e3a2619bd9164b48dc9d9"}, + {file = "pycryptodomex-3.21.0.tar.gz", hash = "sha256:222d0bd05381dd25c32dd6065c071ebf084212ab79bab4599ba9e6a3e0009e6c"}, +] + [[package]] name = "pyflakes" version = "3.2.0" @@ -277,6 +903,77 @@ files = [ {file = "pyflakes-3.2.0.tar.gz", hash = "sha256:1c61603ff154621fb2a9172037d84dca3500def8c8b630657d1701f026f8af3f"}, ] +[[package]] +name = "pyppmd" +version = "1.1.1" +description = "PPMd compression/decompression library" +optional = false +python-versions = ">=3.9" +groups = ["tutorials"] +files = [ + {file = "pyppmd-1.1.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:406b184132c69e3f60ea9621b69eaa0c5494e83f82c307b3acce7b86a4f8f888"}, + {file = "pyppmd-1.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c2cf003bb184adf306e1ac1828107307927737dde63474715ba16462e266cbef"}, + {file = "pyppmd-1.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:71c8fd0ecc8d4760e852dd6df19d1a827427cb9e6c9e568cbf5edba7d860c514"}, + {file = "pyppmd-1.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e6b5edee08b66ad6c39fd4d34a7ef4cfeb4b69fd6d68957e59cd2db674611a9e"}, + {file = "pyppmd-1.1.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e95bd23eb1543ab3149f24fe02f6dd2695023326027a4b989fb2c6dba256e75e"}, + {file = "pyppmd-1.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e633ee4cc19d0c71b3898092c3c4cc20a10bd5e6197229fffac29d68ad5d83b8"}, + {file = "pyppmd-1.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:ecaafe2807ef557f0c49b8476a4fa04091b43866072fbcf31b3ceb01a96c9168"}, + {file = "pyppmd-1.1.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:c182fccff60ae8f24f28f5145c36a60708b5b041a25d36b67f23c44923552fa4"}, + {file = "pyppmd-1.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:70c93d19efe67cdac3e7fa2d4e171650a2c4f90127a9781b25e496a43f12fbbc"}, + {file = "pyppmd-1.1.1-cp310-cp310-win32.whl", hash = "sha256:57c75856920a210ed72b553885af7bc06eddfd30ff26b62a3a63cb8f86f3d217"}, + {file = "pyppmd-1.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:d5293f10dc8c1d571b780e0d54426d3d858c19bbd8cb0fe972dcea3906acd05c"}, + {file = "pyppmd-1.1.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:753c5297c91c059443caef33bccbffb10764221739d218046981638aeb9bc5f2"}, + {file = "pyppmd-1.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9b5a73da09de480a94793c9064876af14a01be117de872737935ac447b7cde3c"}, + {file = "pyppmd-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:89c6febb7114dea02a061143d78d04751a945dfcadff77560e9a3d3c7583c24b"}, + {file = "pyppmd-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0001e467c35e35e6076a8c32ed9074aa45833615ee16115de9282d5c0985a1d8"}, + {file = "pyppmd-1.1.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c76820db25596afc859336ba06c01c9be0ff326480beec9c699fd378a546a77f"}, + {file = "pyppmd-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b67f0a228f8c58750a21ba667c170ae957283e08fd580857f13cb686334e5b3e"}, + {file = "pyppmd-1.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:b18f24c14f0b0f1757a42c458ae7b6fd7aa0bce8147ac1016a9c134068c1ccc2"}, + {file = "pyppmd-1.1.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c9e43729161cc3b6ad5b04b16bae7665d3c0cc803de047d8a979aa9232a4f94a"}, + {file = "pyppmd-1.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fe057d254528b4eeebe2800baefde47d6af679bae184d3793c13a06f794df442"}, + {file = "pyppmd-1.1.1-cp311-cp311-win32.whl", hash = "sha256:faa51240493a5c53c9b544c99722f70303eea702742bf90f3c3064144342da4a"}, + {file = "pyppmd-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:62486f544d6957e1381147e3961eee647b7f4421795be4fb4f1e29d52aee6cb5"}, + {file = "pyppmd-1.1.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:9877ef273e2c0efdec740855e28004a708ada9012e0db6673df4bb6eba3b05e0"}, + {file = "pyppmd-1.1.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f816a5cbccceced80e15335389eeeaf1b56a605fb7eebe135b1c85bd161e288c"}, + {file = "pyppmd-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6bddabf8f2c6b991d15d6785e603d9d414ae4a791f131b1a729bb8a5d31133d1"}, + {file = "pyppmd-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:855bc2b0d19c3fead5815d72dbe350b4f765334336cbf8bcb504d46edc9e9dd2"}, + {file = "pyppmd-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a95b11b3717c083b912f0879678ba72f301bbdb9b69efed46dbc5df682aa3ce7"}, + {file = "pyppmd-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:38b645347b6ea217b0c58e8edac27473802868f152db520344ac8c7490981849"}, + {file = "pyppmd-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:f8f94b6222262def5b532f2b9716554ef249ad8411fd4da303596cc8c2e8eda1"}, + {file = "pyppmd-1.1.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:1c0306f69ceddf385ef689ebd0218325b7e523c48333d87157b37393466cfa1e"}, + {file = "pyppmd-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a4ba510457a56535522a660098399e3fa8722e4de55808d089c9d13435d87069"}, + {file = "pyppmd-1.1.1-cp312-cp312-win32.whl", hash = "sha256:032f040a89fd8348109e8638f94311bd4c3c693fb4cad213ad06a37c203690b1"}, + {file = "pyppmd-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:2be8cbd13dd59fad1a0ad38062809e28596f3673b77a799dfe82b287986265ed"}, + {file = "pyppmd-1.1.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:9458f972f090f3846fc5bea0a6f7363da773d3c4b2d4654f1d4ca3c11f6ecbfa"}, + {file = "pyppmd-1.1.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:44811a9d958873d857ca81cebf7ba646a0952f8a7bbf8a60cf6ec5d002faa040"}, + {file = "pyppmd-1.1.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a1b12460958885ca44e433986644009d0599b87a444f668ce3724a46ce588924"}, + {file = "pyppmd-1.1.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:200c74f05b97b00f047cf60607914a0b50f80991f1fb3677f624a85aa79d9458"}, + {file = "pyppmd-1.1.1-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2ebe0d98a341b32f164e860059243e125398865cc0363b32ffc31f953460fe87"}, + {file = "pyppmd-1.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf93e1e047a82f1e7e194fcf49da166d2b9d8dc98d7c0b5cd844dc4360d9c1f5"}, + {file = "pyppmd-1.1.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:f5b0b8c746bde378ae3b4df42a11fd8599ba3e5808dfea36e16d722b74bd0506"}, + {file = "pyppmd-1.1.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:bcdd5207b6c79887f25639632ca2623a399d8c54f567973e9ba474b5ebae2b1c"}, + {file = "pyppmd-1.1.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:7bfcca94e5452b6d54ac24a11c2402f6a193c331e5dc221c1f1df71773624374"}, + {file = "pyppmd-1.1.1-cp39-cp39-win32.whl", hash = "sha256:18e99c074664f996f511bc6e87aab46bc4c75f5bd0157d3210292919be35e22c"}, + {file = "pyppmd-1.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:b29788d5a0f8f39ea46a1255cd886daddf9c64ba9d4cb64677bc93bd3859ac0e"}, + {file = "pyppmd-1.1.1-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:28648ef56793bf1ed0ff24728642f56fa39cb96ea161dec6ee2d26f97c0cdd28"}, + {file = "pyppmd-1.1.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:427d6f9b9c011e032db9529b2a15773f2e2944ca490b67d5757f4af33bbda406"}, + {file = "pyppmd-1.1.1-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:34c7a07197a03656c1920fd88e05049c155a955c4de4b8b8a8e5fec19a97b45b"}, + {file = "pyppmd-1.1.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e1fea2eee28beca61165c4714dcd032de76af318553791107d308b4b08575ecc"}, + {file = "pyppmd-1.1.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:04391e4f82c8c2c316ba60e480300ad1af37ec12bdb5c20f06b502030ff35975"}, + {file = "pyppmd-1.1.1-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:cf08a354864c352a94e6e53733009baeab1e7c570010c4f5be226923ecfa09d1"}, + {file = "pyppmd-1.1.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:334e5fe5d75764b87c591a16d2b2df6f9939e2ad114dacf98bb4b0e7c90911e9"}, + {file = "pyppmd-1.1.1-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:15d5928b25f04f5431585d17c835cd509a34e1c9f1416653db8d2815e97d4e20"}, + {file = "pyppmd-1.1.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af06329796a4965788910ac40f1b012d2e173ede08456ceea0ec7fc4d2e69d62"}, + {file = "pyppmd-1.1.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:4ccdd3751e432e71e02de96f16fc8824e4f4bfc47a8b470f0c7aae88dae4c666"}, + {file = "pyppmd-1.1.1.tar.gz", hash = "sha256:f1a812f1e7628f4c26d05de340b91b72165d7b62778c27d322b82ce2e8ff00cb"}, +] + +[package.extras] +check = ["check-manifest", "flake8", "flake8-black", "flake8-isort", "mypy (>=1.10.0)", "pygments", "readme-renderer"] +docs = ["sphinx", "sphinx_rtd_theme"] +fuzzer = ["atheris", "hypothesis"] +test = ["coverage[toml] (>=5.2)", "hypothesis", "pytest (>=6.0)", "pytest-benchmark", "pytest-cov", "pytest-timeout"] + [[package]] name = "pyspark" version = "3.5.4" @@ -298,6 +995,133 @@ mllib = ["numpy (>=1.15,<2)"] pandas-on-spark = ["numpy (>=1.15,<2)", "pandas (>=1.0.5)", "pyarrow (>=4.0.0)"] sql = ["numpy (>=1.15,<2)", "pandas (>=1.0.5)", "pyarrow (>=4.0.0)"] +[[package]] +name = "pyzstd" +version = "0.16.2" +description = "Python bindings to Zstandard (zstd) compression library." +optional = false +python-versions = ">=3.5" +groups = ["tutorials"] +files = [ + {file = "pyzstd-0.16.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:637376c8f8cbd0afe1cab613f8c75fd502bd1016bf79d10760a2d5a00905fe62"}, + {file = "pyzstd-0.16.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3e7a7118cbcfa90ca2ddbf9890c7cb582052a9a8cf2b7e2c1bbaf544bee0f16a"}, + {file = "pyzstd-0.16.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a74cb1ba05876179525144511eed3bd5a509b0ab2b10632c1215a85db0834dfd"}, + {file = "pyzstd-0.16.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7c084dde218ffbf112e507e72cbf626b8f58ce9eb23eec129809e31037984662"}, + {file = "pyzstd-0.16.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4646459ebd3d7a59ddbe9312f020bcf7cdd1f059a2ea07051258f7af87a0b31"}, + {file = "pyzstd-0.16.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14bfc2833cc16d7657fc93259edeeaa793286e5031b86ca5dc861ba49b435fce"}, + {file = "pyzstd-0.16.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f27d488f19e5bf27d1e8aa1ae72c6c0a910f1e1ffbdf3c763d02ab781295dd27"}, + {file = "pyzstd-0.16.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:91e134ca968ff7dcfa8b7d433318f01d309b74ee87e0d2bcadc117c08e1c80db"}, + {file = "pyzstd-0.16.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:6b5f64cd3963c58b8f886eb6139bb8d164b42a74f8a1bb95d49b4804f4592d61"}, + {file = "pyzstd-0.16.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:0b4a8266871b9e0407f9fd8e8d077c3558cf124d174e6357b523d14f76971009"}, + {file = "pyzstd-0.16.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:1bb19f7acac30727354c25125922aa59f44d82e0e6a751df17d0d93ff6a73853"}, + {file = "pyzstd-0.16.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3008325b7368e794d66d4d98f2ee1d867ef5afd09fd388646ae02b25343c420d"}, + {file = "pyzstd-0.16.2-cp310-cp310-win32.whl", hash = "sha256:66f2d5c0bbf5bf32c577aa006197b3525b80b59804450e2c32fbcc2d16e850fd"}, + {file = "pyzstd-0.16.2-cp310-cp310-win_amd64.whl", hash = "sha256:5fe5f5459ebe1161095baa7a86d04ab625b35148f6c425df0347ed6c90a2fd58"}, + {file = "pyzstd-0.16.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1c1bdbe7f01c7f37d5cd07be70e32a84010d7dfd6677920c0de04cf7d245b60d"}, + {file = "pyzstd-0.16.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1882a3ceaaf9adc12212d587d150ec5e58cfa9a765463d803d739abbd3ac0f7a"}, + {file = "pyzstd-0.16.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ea46a8b9d60f6a6eba29facba54c0f0d70328586f7ef0da6f57edf7e43db0303"}, + {file = "pyzstd-0.16.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d7865bc06589cdcecdede0deefe3da07809d5b7ad9044c224d7b2a0867256957"}, + {file = "pyzstd-0.16.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:52f938a65b409c02eb825e8c77fc5ea54508b8fc44b5ce226db03011691ae8cc"}, + {file = "pyzstd-0.16.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e97620d3f53a0282947304189deef7ca7f7d0d6dfe15033469dc1c33e779d5e5"}, + {file = "pyzstd-0.16.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7c40e9983d017108670dc8df68ceef14c7c1cf2d19239213274783041d0e64c"}, + {file = "pyzstd-0.16.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7cd4b3b2c6161066e4bde6af1cf78ed3acf5d731884dd13fdf31f1db10830080"}, + {file = "pyzstd-0.16.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:454f31fd84175bb203c8c424f2255a343fa9bd103461a38d1bf50487c3b89508"}, + {file = "pyzstd-0.16.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:5ef754a93743f08fb0386ce3596780bfba829311b49c8f4107af1a4bcc16935d"}, + {file = "pyzstd-0.16.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:be81081db9166e10846934f0e3576a263cbe18d81eca06e6a5c23533f8ce0dc6"}, + {file = "pyzstd-0.16.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:738bcb2fa1e5f1868986f5030955e64de53157fa1141d01f3a4daf07a1aaf644"}, + {file = "pyzstd-0.16.2-cp311-cp311-win32.whl", hash = "sha256:0ea214c9b97046867d1657d55979021028d583704b30c481a9c165191b08d707"}, + {file = "pyzstd-0.16.2-cp311-cp311-win_amd64.whl", hash = "sha256:c17c0fc02f0e75b0c7cd21f8eaf4c6ce4112333b447d93da1773a5f705b2c178"}, + {file = "pyzstd-0.16.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d4081fd841a9efe9ded7290ee7502dbf042c4158b90edfadea3b8a072c8ec4e1"}, + {file = "pyzstd-0.16.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fd3fa45d2aeb65367dd702806b2e779d13f1a3fa2d13d5ec777cfd09de6822de"}, + {file = "pyzstd-0.16.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8b5f0d2c07994a5180d8259d51df6227a57098774bb0618423d7eb4a7303467"}, + {file = "pyzstd-0.16.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:60c9d25b15c7ae06ed5d516d096a0d8254f9bed4368b370a09cccf191eaab5cb"}, + {file = "pyzstd-0.16.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:29acf31ce37254f6cad08deb24b9d9ba954f426fa08f8fae4ab4fdc51a03f4ae"}, + {file = "pyzstd-0.16.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ec77612a17697a9f7cf6634ffcee616eba9b997712fdd896e77fd19ab3a0618"}, + {file = "pyzstd-0.16.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:313ea4974be93be12c9a640ab40f0fc50a023178aae004a8901507b74f190173"}, + {file = "pyzstd-0.16.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e91acdefc8c2c6c3b8d5b1b5fe837dce4e591ecb7c0a2a50186f552e57d11203"}, + {file = "pyzstd-0.16.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:929bd91a403539e72b5b5cb97f725ac4acafe692ccf52f075e20cd9bf6e5493d"}, + {file = "pyzstd-0.16.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:740837a379aa32d110911ebcbbc524f9a9b145355737527543a884bd8777ca4f"}, + {file = "pyzstd-0.16.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:adfc0e80dd157e6d1e0b0112c8ecc4b58a7a23760bd9623d74122ef637cfbdb6"}, + {file = "pyzstd-0.16.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:79b183beae1c080ad3dca39019e49b7785391947f9aab68893ad85d27828c6e7"}, + {file = "pyzstd-0.16.2-cp312-cp312-win32.whl", hash = "sha256:b8d00631a3c466bc313847fab2a01f6b73b3165de0886fb03210e08567ae3a89"}, + {file = "pyzstd-0.16.2-cp312-cp312-win_amd64.whl", hash = "sha256:c0d43764e9a60607f35d8cb3e60df772a678935ab0e02e2804d4147377f4942c"}, + {file = "pyzstd-0.16.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3ae9ae7ad730562810912d7ecaf1fff5eaf4c726f4b4dfe04784ed5f06d7b91f"}, + {file = "pyzstd-0.16.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2ce8d3c213f76a564420f3d0137066ac007ce9fb4e156b989835caef12b367a7"}, + {file = "pyzstd-0.16.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c2c14dac23c865e2d78cebd9087e148674b7154f633afd4709b4cd1520b99a61"}, + {file = "pyzstd-0.16.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4527969d66a943e36ef374eda847e918077de032d58b5df84d98ffd717b6fa77"}, + {file = "pyzstd-0.16.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd8256149b88e657e99f31e6d4b114c8ff2935951f1d8bb8e1fe501b224999c0"}, + {file = "pyzstd-0.16.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5bd1f1822d65c9054bf36d35307bf8ed4aa2d2d6827431761a813628ff671b1d"}, + {file = "pyzstd-0.16.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f6733f4d373ec9ad2c1976cf06f973a3324c1f9abe236d114d6bb91165a397d"}, + {file = "pyzstd-0.16.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7bec165ab6524663f00b69bfefd13a46a69fed3015754abaf81b103ec73d92c6"}, + {file = "pyzstd-0.16.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e4460fa6949aac6528a1ad0de8871079600b12b3ef4db49316306786a3598321"}, + {file = "pyzstd-0.16.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:75df79ea0315c97d88337953a17daa44023dbf6389f8151903d371513f503e3c"}, + {file = "pyzstd-0.16.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:93e1d45f4a196afb6f18682c79bdd5399277ead105b67f30b35c04c207966071"}, + {file = "pyzstd-0.16.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:075e18b871f38a503b5d23e40a661adfc750bd4bd0bb8b208c1e290f3ceb8fa2"}, + {file = "pyzstd-0.16.2-cp313-cp313-win32.whl", hash = "sha256:9e4295eb299f8d87e3487852bca033d30332033272a801ca8130e934475e07a9"}, + {file = "pyzstd-0.16.2-cp313-cp313-win_amd64.whl", hash = "sha256:18deedc70f858f4cf574e59f305d2a0678e54db2751a33dba9f481f91bc71c28"}, + {file = "pyzstd-0.16.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a9892b707ef52f599098b1e9528df0e7849c5ec01d3e8035fb0e67de4b464839"}, + {file = "pyzstd-0.16.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4fbd647864341f3c174c4a6d7f20e6ea6b4be9d840fb900dc0faf0849561badc"}, + {file = "pyzstd-0.16.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20ac2c15656cc6194c4fed1cb0e8159f9394d4ea1d58be755448743d2ec6c9c4"}, + {file = "pyzstd-0.16.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b239fb9a20c1be3374b9a2bd183ba624fd22ad7a3f67738c0d80cda68b4ae1d3"}, + {file = "pyzstd-0.16.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cc52400412cdae2635e0978b8d6bcc0028cc638fdab2fd301f6d157675d26896"}, + {file = "pyzstd-0.16.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b766a6aeb8dbb6c46e622e7a1aebfa9ab03838528273796941005a5ce7257b1"}, + {file = "pyzstd-0.16.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:abd4b8676052f9d59579242bf3cfe5fd02532b6a9a93ab7737c118ae3b8509dc"}, + {file = "pyzstd-0.16.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1c6c0a677aac7c0e3d2d2605d4d68ffa9893fdeeb2e071040eb7c8750969d463"}, + {file = "pyzstd-0.16.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:15f9c2d612e7e2023d68d321d1b479846751f792af89141931d44e82ae391394"}, + {file = "pyzstd-0.16.2-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:11740bff847aad23beef4085a1bb767d101895881fe891f0a911aa27d43c372c"}, + {file = "pyzstd-0.16.2-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:b9067483ebe860e4130a03ee665b3d7be4ec1608b208e645d5e7eb3492379464"}, + {file = "pyzstd-0.16.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:988f0ba19b14c2fe0afefc444ac1edfb2f497b7d7c3212b2f587504cc2ec804e"}, + {file = "pyzstd-0.16.2-cp39-cp39-win32.whl", hash = "sha256:8855acb1c3e3829030b9e9e9973b19e2d70f33efb14ad5c474b4d086864c959c"}, + {file = "pyzstd-0.16.2-cp39-cp39-win_amd64.whl", hash = "sha256:018e88378df5e76f5e1d8cf4416576603b6bc4a103cbc66bb593eaac54c758de"}, + {file = "pyzstd-0.16.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:4b631117b97a42ff6dfd0ffc885a92fff462d7c34766b28383c57b996f863338"}, + {file = "pyzstd-0.16.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:56493a3fbe1b651a02102dd0902b0aa2377a732ff3544fb6fb3f114ca18db52f"}, + {file = "pyzstd-0.16.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f1eae9bdba4a1e5d3181331f403114ff5b8ce0f4b569f48eba2b9beb2deef1e4"}, + {file = "pyzstd-0.16.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e1be6972391c8aeecc7e61feb96ffc8e77a401bcba6ed994e7171330c45a1948"}, + {file = "pyzstd-0.16.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:761439d687e3a5687c2ff5c6a1190e1601362a4a3e8c6c82ff89719d51d73e19"}, + {file = "pyzstd-0.16.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:f5fbdb8cf31b60b2dc586fecb9b73e2f172c21a0b320ed275f7b8d8a866d9003"}, + {file = "pyzstd-0.16.2-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:183f26e34f9becf0f2db38be9c0bfb136753d228bcb47c06c69175901bea7776"}, + {file = "pyzstd-0.16.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:88318b64b5205a67748148d6d244097fa6cf61fcea02ad3435511b9e7155ae16"}, + {file = "pyzstd-0.16.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:73142aa2571b6480136a1865ebda8257e09eabbc8bcd54b222202f6fa4febe1e"}, + {file = "pyzstd-0.16.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1d3f8877c29a97f1b1bba16f3d3ab01ad10ad3da7bad317aecf36aaf8848b37c"}, + {file = "pyzstd-0.16.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d1f25754562473ac7de856b8331ebd5964f5d85601045627a5f0bb0e4e899990"}, + {file = "pyzstd-0.16.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:6ce17e84310080c55c02827ad9bb17893c00a845c8386a328b346f814aabd2c1"}, + {file = "pyzstd-0.16.2.tar.gz", hash = "sha256:179c1a2ea1565abf09c5f2fd72f9ce7c54b2764cf7369e05c0bfd8f1f67f63d2"}, +] + +[[package]] +name = "requests" +version = "2.32.3" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=3.8" +groups = ["tutorials"] +files = [ + {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, + {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, +] + +[package.dependencies] +certifi = ">=2017.4.17" +charset-normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<3" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] + +[[package]] +name = "texttable" +version = "1.7.0" +description = "module to create simple ASCII tables" +optional = false +python-versions = "*" +groups = ["tutorials"] +files = [ + {file = "texttable-1.7.0-py2.py3-none-any.whl", hash = "sha256:72227d592c82b3d7f672731ae73e4d1f88cd8e2ef5b075a7a7f01a23a3743917"}, + {file = "texttable-1.7.0.tar.gz", hash = "sha256:2d2068fb55115807d3ac77a4ca68fa48803e84ebb0ee2340f858107a36522638"}, +] + [[package]] name = "tomli" version = "2.2.1" @@ -354,7 +1178,25 @@ files = [ {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, ] +[[package]] +name = "urllib3" +version = "2.3.0" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=3.9" +groups = ["tutorials"] +files = [ + {file = "urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df"}, + {file = "urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d"}, +] + +[package.extras] +brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +h2 = ["h2 (>=4,<5)"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["zstandard (>=0.18.0)"] + [metadata] lock-version = "2.1" python-versions = ">=3.9 <3.13" -content-hash = "52c129fee3e94e69edf727f219bc7582ddbfcedf6c43547a7f67a876051bd7c4" +content-hash = "33ae7f96a3999d6822af7778f9b7878355d811534a4b5fec14d51ec29aa8dce2" diff --git a/python/pyproject.toml b/python/pyproject.toml index 8c0c1ba05..36097e2c9 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -34,6 +34,11 @@ black = "^25.1.0" flake8 = "^7.1.1" isort = "^6.0.0" +[tool.poetry.group.tutorials.dependencies] +py7zr = "^0.22.0" +requests = "^2.32.3" +click = "^8.1.8" + [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" From c0d6d7b58175a04d08f81de7d574979ea2af4610 Mon Sep 17 00:00:00 2001 From: Russell Jurney Date: Mon, 17 Feb 2025 10:57:04 -0800 Subject: [PATCH 44/70] Make motif.py execute in whole again --- python/graphframes/tutorials/motif.py | 52 ++++++++++++++------------- 1 file changed, 27 insertions(+), 25 deletions(-) diff --git a/python/graphframes/tutorials/motif.py b/python/graphframes/tutorials/motif.py index 4a2189c56..2f5eb030c 100644 --- a/python/graphframes/tutorials/motif.py +++ b/python/graphframes/tutorials/motif.py @@ -16,7 +16,8 @@ spark: SparkSession = ( SparkSession.builder.appName("Stack Overflow Motif Analysis") # Lets the Id:(Stack Overflow int) and id:(GraphFrames ULID) coexist - .config("spark.sql.caseSensitive", True).getOrCreate() + .config("spark.sql.caseSensitive", True) + .getOrCreate() ) sc: SparkContext = spark.sparkContext sc.setCheckpointDir("/tmp/graphframes-checkpoints") @@ -25,8 +26,9 @@ STACKEXCHANGE_SITE = "stats.meta.stackexchange.com" BASE_PATH = f"python/graphframes/tutorials/data/{STACKEXCHANGE_SITE}" + # -# Load the nodes and edges from disk, repartition, checkpoint [plan got long for some reason] and cache. +# Load the nodes and edges from disk, repartition, checkpoint [plan got long for some reason] and cache. # # We created these in stackexchange.py from Stack Exchange data dump XML files @@ -45,7 +47,8 @@ # What kind of nodes we do we have to work with? node_counts = ( - nodes_df.select("id", F.col("Type").alias("Node Type")) + nodes_df + .select("id", F.col("Type").alias("Node Type")) .groupBy("Node Type") .count() .orderBy(F.col("count").desc()) @@ -56,7 +59,8 @@ # What kind of edges do we have to work with? edge_counts = ( - edges_df.select("src", "dst", F.col("relationship").alias("Edge Type")) + edges_df + .select("src", "dst", F.col("relationship").alias("Edge Type")) .groupBy("Edge Type") .count() .orderBy(F.col("count").desc()) @@ -65,7 +69,7 @@ ) edge_counts.show() -g = GraphFrame(nodes_df, edges_df) +g = GraphFrame(nodes_df, edges_df) g.vertices.show(10) print(f"Node columns: {g.vertices.columns}") @@ -166,28 +170,25 @@ ) graphlet_count_df.show() -graphlet_count_df.orderBy( - [ - "A_Type", - "(a)-[e1]->(b)", - "B_Type", - "(b)-[e2]->(c)", - "C_Type", - "(d)-[e3]->(c)", - "D_Type", - ], - ascending=False, -).show(104) +graphlet_count_df.orderBy([ + "A_Type", + "(a)-[e1]->(b)", + "B_Type", + "(b)-[e2]->(c)", + "C_Type", + "(d)-[e3]->(c)", + "D_Type", +], ascending=False).show(104) # A user answers an answer that answers a question that links to an answer. linked_vote_paths = paths.filter( - (F.col("a.Type") == "Vote") - & (F.col("e1.relationship") == "CastFor") - & (F.col("b.Type") == "Question") - & (F.col("e2.relationship") == "Links") - & (F.col("c.Type") == "Question") - & (F.col("e3.relationship") == "CastFor") - & (F.col("d.Type") == "Vote") + (F.col("a.Type") == "Vote") & + (F.col("e1.relationship") == "CastFor") & + (F.col("b.Type") == "Question") & + (F.col("e2.relationship") == "Links") & + (F.col("c.Type") == "Question") & + (F.col("e3.relationship") == "CastFor") & + (F.col("d.Type") == "Vote") ) # Sanity check the count - it should match the table above @@ -197,7 +198,8 @@ c_vote_counts = linked_vote_paths.select("c", "d").distinct().groupBy("c").count() linked_vote_counts = ( - linked_vote_paths.filter((F.col("a.VoteTypeId") == 2) & (F.col("d.VoteTypeId") == 2)) + linked_vote_paths + .filter((F.col("a.VoteTypeId") == 2) & (F.col("d.VoteTypeId") == 2)) .select("b", "c") .join(b_vote_counts, on="b", how="inner") .withColumnRenamed("count", "b_count") From 5bb4c26b101b524193076e2870a5f5894e0a9c16 Mon Sep 17 00:00:00 2001 From: Russell Jurney Date: Mon, 17 Feb 2025 11:55:50 -0800 Subject: [PATCH 45/70] Minor isort format and cleanup of download.py --- python/graphframes/tutorials/download.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/python/graphframes/tutorials/download.py b/python/graphframes/tutorials/download.py index 154d84c14..e81eff8b9 100755 --- a/python/graphframes/tutorials/download.py +++ b/python/graphframes/tutorials/download.py @@ -1,14 +1,21 @@ #!/usr/bin/env python +"""Download and decompress the Stack Exchange data dump from the Internet Archive.""" + import os + import click -import requests import py7zr +import requests # type: ignore @click.command() @click.argument("subdomain") -@click.option("--data-dir", default="python/graphframes/tutorials/data", help="Directory to store downloaded files") +@click.option( + "--data-dir", + default="python/graphframes/tutorials/data", + help="Directory to store downloaded files", +) @click.option( "--extract/--no-extract", default=True, help="Whether to extract the archive after download" ) From 99e6a4d14e6eb7cdc2c001ebefc1c3312ff43ced Mon Sep 17 00:00:00 2001 From: Russell Jurney Date: Mon, 17 Feb 2025 11:56:13 -0800 Subject: [PATCH 46/70] Minor isort format and cleanup of utils.py --- python/graphframes/tutorials/utils.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/python/graphframes/tutorials/utils.py b/python/graphframes/tutorials/utils.py index 54ef40f8b..46db14d96 100644 --- a/python/graphframes/tutorials/utils.py +++ b/python/graphframes/tutorials/utils.py @@ -1,7 +1,10 @@ +"""Utilities for Network Moitif Finding Tutorial""" + from pyspark.sql import DataFrame -from graphframes import GraphFrame from pyspark.sql import functions as F +from graphframes import GraphFrame + def three_edge_count(paths: DataFrame) -> DataFrame: """three_edge_count View the counts of the different types of 3-node graphlets in the graph. From 662e197960a424c1f58c151b663c46d9d63da6be Mon Sep 17 00:00:00 2001 From: Russell Jurney Date: Mon, 17 Feb 2025 11:57:40 -0800 Subject: [PATCH 47/70] Removed case sensitivity from the script - that was confusing people who just pasted or tried to run the code without a new SparkSession. --- python/graphframes/tutorials/stackexchange.py | 48 ++++++++++++------- 1 file changed, 32 insertions(+), 16 deletions(-) diff --git a/python/graphframes/tutorials/stackexchange.py b/python/graphframes/tutorials/stackexchange.py index c52f323bb..5e029746e 100644 --- a/python/graphframes/tutorials/stackexchange.py +++ b/python/graphframes/tutorials/stackexchange.py @@ -1,4 +1,4 @@ -# Build a Graph out of the Stack Exchange Data Dump XML files +"""Build a Graph out of the Stack Exchange Data Dump XML files.""" # # Interactive Usage: pyspark --packages com.databricks:spark-xml_2.12:0.18.0 @@ -47,11 +47,9 @@ def split_tags(tags: str) -> List[str]: # Initialize a SparkSession with case sensitivity # -spark: SparkSession = ( - SparkSession.builder.appName("Stack Exchange Graph Builder") - # Lets the Id:(Stack Overflow int) and id:(GraphFrames UUID) coexist - .config("spark.sql.caseSensitive", True).getOrCreate() -) +spark: SparkSession = SparkSession.builder.appName("Stack Exchange Graph Builder").getOrCreate() +sc = spark.sparkContext +sc.setCheckpointDir("/tmp/graphframes-checkpoints") print("Loading data for stats.meta.stackexchange.com ...") @@ -296,12 +294,23 @@ def add_missing_columns(df: DataFrame, all_cols: List[Tuple[str, T.StructField]] ) print(f"Total distinct nodes: {nodes_df.count():,}") -# Now add a unique ID field +# Now add a unique lowercase 'id' field - standard for GraphFrames - moving the original... +# Stack Exchange Id to StackId +nodes_df = nodes_df.withColumnRenamed("Id", "StackId").drop("Id") + +# Update the column list... +if "Id" in all_column_names: + all_column_names.remove("Id") +all_column_names += ["StackId"] +all_column_names = sorted(all_column_names) + +# Add the UUID 'id' field for GraphFrames. It will go in edges as 'src' and 'dst' nodes_df = nodes_df.withColumn("id", F.expr("uuid()")).select("id", *all_column_names) # Now create posts - combined questions and answers for things that can apply to them both posts_df = questions_df.unionByName(answers_df).cache() + # # Store the nodes to disk, reload and cache # @@ -361,12 +370,12 @@ def add_missing_columns(df: DataFrame, all_cols: List[Tuple[str, T.StructField]] src_vote_df: DataFrame = votes_df.select( F.col("id").alias("src"), - F.col("Id").alias("VoteId"), + F.col("StackId").alias("VoteId"), # Everything has all the fields - should build from base records but need UUIDs F.col("PostId").alias("VotePostId"), ) cast_for_edge_df: DataFrame = src_vote_df.join( - posts_df, on=src_vote_df.VotePostId == posts_df.Id, how="inner" + posts_df, on=src_vote_df.VotePostId == posts_df.StackId, how="inner" ).select( # 'src' comes from the votes' 'id' "src", @@ -378,6 +387,7 @@ def add_missing_columns(df: DataFrame, all_cols: List[Tuple[str, T.StructField]] print(f"Total CastFor edges: {cast_for_edge_df.count():,}") print(f"Percentage of linked votes: {cast_for_edge_df.count() / votes_df.count():.2%}\n") + # # Create a [User]--Asks-->[Question] edge # @@ -388,7 +398,7 @@ def add_missing_columns(df: DataFrame, all_cols: List[Tuple[str, T.StructField]] F.lit("Asks").alias("relationship"), ) user_asks_edges_df: DataFrame = questions_asked_df.join( - users_df, on=questions_asked_df.QuestionUserId == users_df.Id, how="inner" + users_df, on=questions_asked_df.QuestionUserId == users_df.StackId, how="inner" ).select( # 'src' comes from the users' 'id' F.col("id").alias("src"), @@ -402,6 +412,7 @@ def add_missing_columns(df: DataFrame, all_cols: List[Tuple[str, T.StructField]] f"Percentage of asked questions linked to users: {user_asks_edges_df.count() / questions_df.count():.2%}\n" ) + # # Create a [User]--Posts-->[Answer] edge. # @@ -412,7 +423,7 @@ def add_missing_columns(df: DataFrame, all_cols: List[Tuple[str, T.StructField]] F.lit("Posts").alias("relationship"), ) user_answers_edges_df = user_answers_df.join( - users_df, on=user_answers_df.AnswerUserId == users_df.Id, how="inner" + users_df, on=user_answers_df.AnswerUserId == users_df.StackId, how="inner" ).select( # 'src' comes from the users' 'id' F.col("id").alias("src"), @@ -426,17 +437,18 @@ def add_missing_columns(df: DataFrame, all_cols: List[Tuple[str, T.StructField]] f"Percentage of answers linked to users: {user_answers_edges_df.count() / answers_df.count():.2%}\n" ) + # # Create a [Answer]--Answers-->[Question] edge # src_answers_df: DataFrame = answers_df.select( F.col("id").alias("src"), - F.col("Id").alias("AnswerId"), + F.col("StackId").alias("AnswerId"), F.col("ParentId").alias("AnswerParentId"), ) question_answers_edges_df: DataFrame = src_answers_df.join( - posts_df, on=src_answers_df.AnswerParentId == questions_df.Id, how="inner" + posts_df, on=src_answers_df.AnswerParentId == questions_df.StackId, how="inner" ).select( # 'src' comes from the answers' 'id' "src", @@ -450,6 +462,7 @@ def add_missing_columns(df: DataFrame, all_cols: List[Tuple[str, T.StructField]] f"Percentage of linked answers: {question_answers_edges_df.count() / answers_df.count():.2%}\n" ) + # # Create a [Tag]--Tags-->[Post] edge... remember a Post is a Question or Answer # @@ -472,6 +485,7 @@ def add_missing_columns(df: DataFrame, all_cols: List[Tuple[str, T.StructField]] print(f"Total Tags edges: {tags_edge_df.count():,}") print(f"Percentage of linked tags: {tags_edge_df.count() / posts_df.count():.2%}\n") + # # Create a [User]--Earns-->[Badge] edge # @@ -482,7 +496,7 @@ def add_missing_columns(df: DataFrame, all_cols: List[Tuple[str, T.StructField]] F.lit("Earns").alias("relationship"), ) earns_edges_df = earns_edges_df.join( - users_df, on=earns_edges_df.BadgeUserId == users_df.Id, how="inner" + users_df, on=earns_edges_df.BadgeUserId == users_df.StackId, how="inner" ).select( # 'src' comes from the users' 'id' F.col("id").alias("src"), @@ -494,6 +508,7 @@ def add_missing_columns(df: DataFrame, all_cols: List[Tuple[str, T.StructField]] print(f"Total Earns edges: {earns_edges_df.count():,}") print(f"Percentage of earned badges: {earns_edges_df.count() / badges_df.count():.2%}\n") + # # Create a [Post]--Links-->[Post] edge... remember a Post is a Question or Answer # Also a [Post]--Duplicates-->[Post] edge... remember a Post is a Question or Answer @@ -505,7 +520,7 @@ def add_missing_columns(df: DataFrame, all_cols: List[Tuple[str, T.StructField]] "LinkType", ) links_src_edge_df: DataFrame = trim_links_df.join( - posts_df.drop("LinkType"), on=trim_links_df.SrcPostId == posts_df.Id, how="inner" + posts_df.drop("LinkType"), on=trim_links_df.SrcPostId == posts_df.StackId, how="inner" ).select( # 'dst' comes from the posts' 'id' F.col("id").alias("src"), @@ -513,7 +528,7 @@ def add_missing_columns(df: DataFrame, all_cols: List[Tuple[str, T.StructField]] "LinkType", ) raw_links_edge_df = links_src_edge_df.join( - posts_df.drop("LinkType"), on=links_src_edge_df.DstPostId == posts_df.Id, how="inner" + posts_df.drop("LinkType"), on=links_src_edge_df.DstPostId == posts_df.StackId, how="inner" ).select( "src", # 'src' comes from the posts' 'id' @@ -557,6 +572,7 @@ def add_missing_columns(df: DataFrame, all_cols: List[Tuple[str, T.StructField]] "count", F.format_number(F.col("count"), 0) ).show() + # +------------+------+ # |relationship| count| # +------------+------+ From beaa35d60be2a8635e3f2743b3543631875cadcb Mon Sep 17 00:00:00 2001 From: Russell Jurney Date: Mon, 17 Feb 2025 11:58:29 -0800 Subject: [PATCH 48/70] motif.py now matches tutorial code, runs and handles case insensitivity. --- python/graphframes/tutorials/motif.py | 57 ++++++++++++--------------- 1 file changed, 26 insertions(+), 31 deletions(-) diff --git a/python/graphframes/tutorials/motif.py b/python/graphframes/tutorials/motif.py index 2f5eb030c..a4a82953a 100644 --- a/python/graphframes/tutorials/motif.py +++ b/python/graphframes/tutorials/motif.py @@ -1,4 +1,4 @@ -# Demonstrate GraphFrames network motif finding capabilities +"""Demonstrate GraphFrames network motif finding capabilities. Code from the Network Motif Finding Tutorial.""" # # Interactive Usage: pyspark --packages graphframes:graphframes:0.8.4-spark3.5-s_2.12 @@ -13,12 +13,7 @@ from graphframes import GraphFrame # Initialize a SparkSession -spark: SparkSession = ( - SparkSession.builder.appName("Stack Overflow Motif Analysis") - # Lets the Id:(Stack Overflow int) and id:(GraphFrames ULID) coexist - .config("spark.sql.caseSensitive", True) - .getOrCreate() -) +spark: SparkSession = SparkSession.builder.appName("Stack Overflow Motif Analysis").getOrCreate() sc: SparkContext = spark.sparkContext sc.setCheckpointDir("/tmp/graphframes-checkpoints") @@ -28,7 +23,7 @@ # -# Load the nodes and edges from disk, repartition, checkpoint [plan got long for some reason] and cache. +# Load the nodes and edges from disk, repartition, checkpoint [plan got long for some reason] and cache. # # We created these in stackexchange.py from Stack Exchange data dump XML files @@ -47,8 +42,7 @@ # What kind of nodes we do we have to work with? node_counts = ( - nodes_df - .select("id", F.col("Type").alias("Node Type")) + nodes_df.select("id", F.col("Type").alias("Node Type")) .groupBy("Node Type") .count() .orderBy(F.col("count").desc()) @@ -59,8 +53,7 @@ # What kind of edges do we have to work with? edge_counts = ( - edges_df - .select("src", "dst", F.col("relationship").alias("Edge Type")) + edges_df.select("src", "dst", F.col("relationship").alias("Edge Type")) .groupBy("Edge Type") .count() .orderBy(F.col("count").desc()) @@ -69,7 +62,7 @@ ) edge_counts.show() -g = GraphFrame(nodes_df, edges_df) +g = GraphFrame(nodes_df, edges_df) g.vertices.show(10) print(f"Node columns: {g.vertices.columns}") @@ -170,25 +163,28 @@ ) graphlet_count_df.show() -graphlet_count_df.orderBy([ - "A_Type", - "(a)-[e1]->(b)", - "B_Type", - "(b)-[e2]->(c)", - "C_Type", - "(d)-[e3]->(c)", - "D_Type", -], ascending=False).show(104) +graphlet_count_df.orderBy( + [ + "A_Type", + "(a)-[e1]->(b)", + "B_Type", + "(b)-[e2]->(c)", + "C_Type", + "(d)-[e3]->(c)", + "D_Type", + ], + ascending=False, +).show(104) # A user answers an answer that answers a question that links to an answer. linked_vote_paths = paths.filter( - (F.col("a.Type") == "Vote") & - (F.col("e1.relationship") == "CastFor") & - (F.col("b.Type") == "Question") & - (F.col("e2.relationship") == "Links") & - (F.col("c.Type") == "Question") & - (F.col("e3.relationship") == "CastFor") & - (F.col("d.Type") == "Vote") + (F.col("a.Type") == "Vote") + & (F.col("e1.relationship") == "CastFor") + & (F.col("b.Type") == "Question") + & (F.col("e2.relationship") == "Links") + & (F.col("c.Type") == "Question") + & (F.col("e3.relationship") == "CastFor") + & (F.col("d.Type") == "Vote") ) # Sanity check the count - it should match the table above @@ -198,8 +194,7 @@ c_vote_counts = linked_vote_paths.select("c", "d").distinct().groupBy("c").count() linked_vote_counts = ( - linked_vote_paths - .filter((F.col("a.VoteTypeId") == 2) & (F.col("d.VoteTypeId") == 2)) + linked_vote_paths.filter((F.col("a.VoteTypeId") == 2) & (F.col("d.VoteTypeId") == 2)) .select("b", "c") .join(b_vote_counts, on="b", how="inner") .withColumnRenamed("count", "b_count") From ef19784b9dd1befdab3d422fadf660139291f9b8 Mon Sep 17 00:00:00 2001 From: Russell Jurney Date: Fri, 21 Feb 2025 11:11:31 +0100 Subject: [PATCH 49/70] Setup a 'graphframes stackexchange' comand. --- python/graphframes/console.py | 19 +++++++++++++++++++ python/graphframes/tutorials/download.py | 4 ++-- python/pyproject.toml | 6 ++++++ 3 files changed, 27 insertions(+), 2 deletions(-) create mode 100644 python/graphframes/console.py diff --git a/python/graphframes/console.py b/python/graphframes/console.py new file mode 100644 index 000000000..d2b38d28b --- /dev/null +++ b/python/graphframes/console.py @@ -0,0 +1,19 @@ +import click +from graphframes.tutorials import download + + +@click.group() +def cli(): + """GraphFrames CLI: a collection of commands for graphframes.""" + pass + + +cli.add_command(download.stackexchange) + + +def main(): + cli() + + +if __name__ == "__main__": + main() diff --git a/python/graphframes/tutorials/download.py b/python/graphframes/tutorials/download.py index e81eff8b9..049b1fa15 100755 --- a/python/graphframes/tutorials/download.py +++ b/python/graphframes/tutorials/download.py @@ -19,7 +19,7 @@ @click.option( "--extract/--no-extract", default=True, help="Whether to extract the archive after download" ) -def download_stackexchange(subdomain: str, data_dir: str, extract: bool) -> None: +def stackexchange(subdomain: str, data_dir: str, extract: bool) -> None: """Download Stack Exchange archive for a given SUBDOMAIN. Example: python/graphframes/tutorials/download.py stats.meta @@ -68,4 +68,4 @@ def download_stackexchange(subdomain: str, data_dir: str, extract: bool) -> None if __name__ == "__main__": - download_stackexchange() + stackexchange() diff --git a/python/pyproject.toml b/python/pyproject.toml index 36097e2c9..819d2bbdd 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -39,6 +39,9 @@ py7zr = "^0.22.0" requests = "^2.32.3" click = "^8.1.8" +[tool.poetry.scripts] +graphframes = "graphframes.console:main" + [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" @@ -48,6 +51,9 @@ line-length = 100 target-version = ["py39"] include = ["graphframes"] +[tool.flake8] +max-line-length = 100 + [tool.isort] profile = "black" src_paths = ["graphframes"] From 4400cb4335a9237363ee033c40e44cbb7b3041c0 Mon Sep 17 00:00:00 2001 From: Russell Jurney Date: Fri, 21 Feb 2025 11:13:29 +0100 Subject: [PATCH 50/70] Make graphframes.tutorials.motif use a checkpoint dir unique, and from SparkSession.sparkContext. Use click.echo instead of print --- python/graphframes/tutorials/motif.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/python/graphframes/tutorials/motif.py b/python/graphframes/tutorials/motif.py index a4a82953a..59691946a 100644 --- a/python/graphframes/tutorials/motif.py +++ b/python/graphframes/tutorials/motif.py @@ -6,16 +6,15 @@ # Batch Usage: spark-submit --packages graphframes:graphframes:0.8.4-spark3.5-s_2.12 python/graphframes/tutorials/motif.py # +import click import pyspark.sql.functions as F -from pyspark import SparkContext from pyspark.sql import DataFrame, SparkSession from graphframes import GraphFrame # Initialize a SparkSession spark: SparkSession = SparkSession.builder.appName("Stack Overflow Motif Analysis").getOrCreate() -sc: SparkContext = spark.sparkContext -sc.setCheckpointDir("/tmp/graphframes-checkpoints") +spark.sparkContext.setCheckpointDir("/tmp/graphframes-checkpoints/motif") # Change me if you download a different stackexchange site STACKEXCHANGE_SITE = "stats.meta.stackexchange.com" @@ -65,7 +64,7 @@ g = GraphFrame(nodes_df, edges_df) g.vertices.show(10) -print(f"Node columns: {g.vertices.columns}") +click.echo(f"Node columns: {g.vertices.columns}") g.edges.sample(0.0001).show(10) @@ -82,7 +81,7 @@ assert ( edge_count == valid_edge_count ), f"Edge count {edge_count} != valid edge count {valid_edge_count}" -print(f"Edge count: {edge_count:,} == Valid edge count: {valid_edge_count:,}") +click.echo(f"Edge count: {edge_count:,} == Valid edge count: {valid_edge_count:,}") # G4: Continuous Triangles paths = g.find("(a)-[e1]->(b); (b)-[e2]->(c); (c)-[e3]->(a)") From d549c566c7a500d4b16319851e88f8ffbd4df61e Mon Sep 17 00:00:00 2001 From: Russell Jurney Date: Fri, 21 Feb 2025 11:23:19 +0100 Subject: [PATCH 51/70] Use spark.sparkContext.setCheckpointDir directly instead of instantiating a SparkContext. print-->click.echo --- python/graphframes/tutorials/stackexchange.py | 63 +++++++++---------- 1 file changed, 31 insertions(+), 32 deletions(-) diff --git a/python/graphframes/tutorials/stackexchange.py b/python/graphframes/tutorials/stackexchange.py index 5e029746e..72185c446 100644 --- a/python/graphframes/tutorials/stackexchange.py +++ b/python/graphframes/tutorials/stackexchange.py @@ -5,10 +5,10 @@ # # Batch Usage: spark-submit --packages com.databricks:spark-xml_2.12:0.18.0 python/graphframes/tutorials/stackexchange.py # - import re from typing import List, Tuple +import click import pyspark.sql.functions as F import pyspark.sql.types as T from pyspark.sql import DataFrame, SparkSession @@ -48,10 +48,9 @@ def split_tags(tags: str) -> List[str]: # spark: SparkSession = SparkSession.builder.appName("Stack Exchange Graph Builder").getOrCreate() -sc = spark.sparkContext -sc.setCheckpointDir("/tmp/graphframes-checkpoints") +spark.sparkContext.setCheckpointDir("/tmp/graphframes-checkpoints/stackexchange") -print("Loading data for stats.meta.stackexchange.com ...") +click.echo("Loading data for stats.meta.stackexchange.com ...") # @@ -63,7 +62,7 @@ def split_tags(tags: str) -> List[str]: .options(rootTag="posts") .load(f"{BASE_PATH}/Posts.xml") ) -print(f"\nTotal Posts: {posts_df.count():,}") +click.echo(f"\nTotal Posts: {posts_df.count():,}") # Remove the _ prefix from field names posts_df = remove_prefix(posts_df) @@ -85,14 +84,14 @@ def split_tags(tags: str) -> List[str]: # Do the questions look ok? Questions have NO parent ID and DO have a Title questions_df: DataFrame = posts_df.filter(posts_df.ParentId.isNull()) questions_df = questions_df.withColumn("Type", F.lit("Question")).cache() -print(f"\nTotal questions: {questions_df.count():,}\n") +click.echo(f"\nTotal questions: {questions_df.count():,}\n") questions_df.select("ParentId", "Title", "Body").show(10) # Answers DO have a ParentId parent post and no Title answers_df: DataFrame = posts_df.filter(posts_df.ParentId.isNotNull()) answers_df = answers_df.withColumn("Type", F.lit("Answer")).cache() -print(f"\nTotal answers: {answers_df.count():,}\n") +click.echo(f"\nTotal answers: {answers_df.count():,}\n") answers_df.select("ParentId", "Title", "Body").show(10) @@ -107,7 +106,7 @@ def split_tags(tags: str) -> List[str]: .options(rootTag="postlinks") .load(f"{BASE_PATH}/PostLinks.xml") ) -print(f"Total PostLinks: {post_links_df.count():,}") +click.echo(f"Total PostLinks: {post_links_df.count():,}") # Remove the _ prefix from field names post_links_df = ( @@ -132,7 +131,7 @@ def split_tags(tags: str) -> List[str]: .options(rootTag="posthistory") .load(f"{BASE_PATH}/PostHistory.xml") ) -print(f"Total PostHistory: {post_history_df.count():,}") +click.echo(f"Total PostHistory: {post_history_df.count():,}") # Remove the _ prefix from field names post_history_df = remove_prefix(post_history_df).withColumn("Type", F.lit("PostHistory")) @@ -148,7 +147,7 @@ def split_tags(tags: str) -> List[str]: .options(rootTag="comments") .load(f"{BASE_PATH}/Comments.xml") ) -print(f"Total Comments: {comments_df.count():,}") +click.echo(f"Total Comments: {comments_df.count():,}") # Remove the _ prefix from field names comments_df = remove_prefix(comments_df).withColumn("Type", F.lit("Comment")) @@ -164,7 +163,7 @@ def split_tags(tags: str) -> List[str]: .options(rootTag="users") .load(f"{BASE_PATH}/Users.xml") ) -print(f"Total Users: {users_df.count():,}") +click.echo(f"Total Users: {users_df.count():,}") # Remove the _ prefix from field names users_df = remove_prefix(users_df).withColumn("Type", F.lit("User")) @@ -180,7 +179,7 @@ def split_tags(tags: str) -> List[str]: .options(rootTag="votes") .load(f"{BASE_PATH}/Votes.xml") ) -print(f"Total Votes: {votes_df.count():,}") +click.echo(f"Total Votes: {votes_df.count():,}") # Remove the _ prefix from field names votes_df = remove_prefix(votes_df).withColumn("Type", F.lit("Vote")) @@ -213,7 +212,7 @@ def split_tags(tags: str) -> List[str]: .options(rootTag="tags") .load(f"{BASE_PATH}/Tags.xml") ) -print(f"Total Tags: {tags_df.count():,}") +click.echo(f"Total Tags: {tags_df.count():,}") # Remove the _ prefix from field names tags_df = remove_prefix(tags_df).withColumn("Type", F.lit("Tag")) @@ -229,7 +228,7 @@ def split_tags(tags: str) -> List[str]: .options(rootTag="badges") .load(f"{BASE_PATH}/Badges.xml") ) -print(f"Total Badges: {badges_df.count():,}\n") +click.echo(f"Total Badges: {badges_df.count():,}\n") # Remove the _ prefix from field names badges_df = remove_prefix(badges_df).withColumn("Type", F.lit("Badge")) @@ -292,7 +291,7 @@ def add_missing_columns(df: DataFrame, all_cols: List[Tuple[str, T.StructField]] .unionByName(badges_df) .distinct() ) -print(f"Total distinct nodes: {nodes_df.count():,}") +click.echo(f"Total distinct nodes: {nodes_df.count():,}") # Now add a unique lowercase 'id' field - standard for GraphFrames - moving the original... # Stack Exchange Id to StackId @@ -384,8 +383,8 @@ def add_missing_columns(df: DataFrame, all_cols: List[Tuple[str, T.StructField]] # All edges have a 'relationship' field F.lit("CastFor").alias("relationship"), ) -print(f"Total CastFor edges: {cast_for_edge_df.count():,}") -print(f"Percentage of linked votes: {cast_for_edge_df.count() / votes_df.count():.2%}\n") +click.echo(f"Total CastFor edges: {cast_for_edge_df.count():,}") +click.echo(f"Percentage of linked votes: {cast_for_edge_df.count() / votes_df.count():.2%}\n") # @@ -407,8 +406,8 @@ def add_missing_columns(df: DataFrame, all_cols: List[Tuple[str, T.StructField]] # All edges have a 'relationship' field "relationship", ) -print(f"Total Asks edges: {user_asks_edges_df.count():,}") -print( +click.echo(f"Total Asks edges: {user_asks_edges_df.count():,}") +click.echo( f"Percentage of asked questions linked to users: {user_asks_edges_df.count() / questions_df.count():.2%}\n" ) @@ -432,8 +431,8 @@ def add_missing_columns(df: DataFrame, all_cols: List[Tuple[str, T.StructField]] # All edges have a 'relationship' field "relationship", ) -print(f"Total User Answers edges: {user_answers_edges_df.count():,}") -print( +click.echo(f"Total User Answers edges: {user_answers_edges_df.count():,}") +click.echo( f"Percentage of answers linked to users: {user_answers_edges_df.count() / answers_df.count():.2%}\n" ) @@ -457,8 +456,8 @@ def add_missing_columns(df: DataFrame, all_cols: List[Tuple[str, T.StructField]] # All edges have a 'relationship' field F.lit("Answers").alias("relationship"), ) -print(f"Total Posts Answers edges: {question_answers_edges_df.count():,}") -print( +click.echo(f"Total Posts Answers edges: {question_answers_edges_df.count():,}") +click.echo( f"Percentage of linked answers: {question_answers_edges_df.count() / answers_df.count():.2%}\n" ) @@ -482,8 +481,8 @@ def add_missing_columns(df: DataFrame, all_cols: List[Tuple[str, T.StructField]] # All edges have a 'relationship' field F.lit("Tags").alias("relationship"), ) -print(f"Total Tags edges: {tags_edge_df.count():,}") -print(f"Percentage of linked tags: {tags_edge_df.count() / posts_df.count():.2%}\n") +click.echo(f"Total Tags edges: {tags_edge_df.count():,}") +click.echo(f"Percentage of linked tags: {tags_edge_df.count() / posts_df.count():.2%}\n") # @@ -505,8 +504,8 @@ def add_missing_columns(df: DataFrame, all_cols: List[Tuple[str, T.StructField]] # All edges have a 'relationship' field "relationship", ) -print(f"Total Earns edges: {earns_edges_df.count():,}") -print(f"Percentage of earned badges: {earns_edges_df.count() / badges_df.count():.2%}\n") +click.echo(f"Total Earns edges: {earns_edges_df.count():,}") +click.echo(f"Percentage of earned badges: {earns_edges_df.count() / badges_df.count():.2%}\n") # @@ -543,16 +542,16 @@ def add_missing_columns(df: DataFrame, all_cols: List[Tuple[str, T.StructField]] .withColumn("relationship", F.lit("Duplicates")) .select("src", "dst", "relationship") ) -print(f"Total Duplicates edges: {duplicates_edge_df.count():,}") -print(f"Percentage of duplicate posts: {duplicates_edge_df.count() / post_links_df.count():.2%}\n") +click.echo(f"Total Duplicates edges: {duplicates_edge_df.count():,}") +click.echo(f"Percentage of duplicate posts: {duplicates_edge_df.count() / post_links_df.count():.2%}\n") linked_edge_df = ( raw_links_edge_df.filter(F.col("LinkType") == "Linked") .withColumn("relationship", F.lit("Links")) .select("src", "dst", "relationship") ) -print(f"Total Links edges: {linked_edge_df.count():,}") -print(f"Percentage of linked posts: {linked_edge_df.count() / post_links_df.count():.2%}\n") +click.echo(f"Total Links edges: {linked_edge_df.count():,}") +click.echo(f"Percentage of linked posts: {linked_edge_df.count() / post_links_df.count():.2%}\n") # @@ -592,4 +591,4 @@ def add_missing_columns(df: DataFrame, all_cols: List[Tuple[str, T.StructField]] relationships_df.write.mode("overwrite").parquet(EDGES_PATH) spark.stop() -print("Spark stopped.") +click.echo("Spark stopped.") From b97063677aca43d918ad775469a58cff39eefb3a Mon Sep 17 00:00:00 2001 From: Russell Jurney Date: Fri, 21 Feb 2025 11:49:44 +0100 Subject: [PATCH 52/70] Using 'from __future__ import annotations' intsead of List and Tuple --- python/graphframes/tutorials/stackexchange.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/python/graphframes/tutorials/stackexchange.py b/python/graphframes/tutorials/stackexchange.py index 72185c446..02ebb2bb5 100644 --- a/python/graphframes/tutorials/stackexchange.py +++ b/python/graphframes/tutorials/stackexchange.py @@ -5,8 +5,9 @@ # # Batch Usage: spark-submit --packages com.databricks:spark-xml_2.12:0.18.0 python/graphframes/tutorials/stackexchange.py # +from __future__ import annotations + import re -from typing import List, Tuple import click import pyspark.sql.functions as F @@ -36,7 +37,7 @@ def remove_prefix(df: DataFrame) -> DataFrame: @F.udf(returnType=T.ArrayType(T.StringType())) -def split_tags(tags: str) -> List[str]: +def split_tags(tags: str) -> list[str]: if not tags: return [] # Remove < and > and split into array @@ -238,7 +239,7 @@ def split_tags(tags: str) -> List[str]: # Form the nodes from the UNION of posts, users, votes and their combined schemas # -all_cols: List[Tuple[str, T.StructField]] = list( +all_cols: list[tuple[str, T.StructField]] = list( set( list(zip(answers_df.columns, answers_df.schema)) + list(zip(questions_df.columns, questions_df.schema)) @@ -250,10 +251,10 @@ def split_tags(tags: str) -> List[str]: + list(zip(badges_df.columns, badges_df.schema)) ) ) -all_column_names: List[str] = sorted([x[0] for x in all_cols]) +all_column_names: list[str] = sorted([x[0] for x in all_cols]) -def add_missing_columns(df: DataFrame, all_cols: List[Tuple[str, T.StructField]]) -> DataFrame: +def add_missing_columns(df: DataFrame, all_cols: list[tuple[str, T.StructField]]) -> DataFrame: """Add any missing columns from any DataFrame among several we want to merge.""" for col_name, schema_field in all_cols: if col_name not in df.columns: @@ -543,7 +544,9 @@ def add_missing_columns(df: DataFrame, all_cols: List[Tuple[str, T.StructField]] .select("src", "dst", "relationship") ) click.echo(f"Total Duplicates edges: {duplicates_edge_df.count():,}") -click.echo(f"Percentage of duplicate posts: {duplicates_edge_df.count() / post_links_df.count():.2%}\n") +click.echo( + f"Percentage of duplicate posts: {duplicates_edge_df.count() / post_links_df.count():.2%}\n" +) linked_edge_df = ( raw_links_edge_df.filter(F.col("LinkType") == "Linked") From 378894125e6baff2e0a6deab0635224e05f3ad26 Mon Sep 17 00:00:00 2001 From: Russell Jurney Date: Fri, 21 Feb 2025 12:11:18 +0100 Subject: [PATCH 53/70] Now retry three times if we can't connect for any reason in 'graphframes stackexchange' command. --- python/graphframes/tutorials/download.py | 25 ++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/python/graphframes/tutorials/download.py b/python/graphframes/tutorials/download.py index 049b1fa15..4eadfa647 100755 --- a/python/graphframes/tutorials/download.py +++ b/python/graphframes/tutorials/download.py @@ -36,13 +36,30 @@ def stackexchange(subdomain: str, data_dir: str, extract: bool) -> None: click.echo(f"Downloading archive from {archive_url}") try: - # Download the file - response = requests.get(archive_url, stream=True) - response.raise_for_status() # Raise exception for bad status codes + # Download the file with retries + max_retries = 3 + retry_count = 0 + + while retry_count < max_retries: + try: + response = requests.get(archive_url, stream=True) + response.raise_for_status() # Raise exception for bad status codes + break + except ( + requests.exceptions.RequestException, + requests.exceptions.ConnectionError, + requests.exceptions.HTTPError, + requests.exceptions.Timeout, + ) as e: + retry_count += 1 + if retry_count == max_retries: + click.echo(f"Failed to download after {max_retries} attempts: {e}", err=True) + raise click.Abort() + click.echo(f"Download attempt {retry_count} failed, retrying...") total_size = int(response.headers.get("content-length", 0)) - with click.progressbar(length=total_size, label="Downloading") as bar: + with click.progressbar(length=total_size, label="Downloading") as bar: # type: ignore with open(archive_path, "wb") as f: for chunk in response.iter_content(chunk_size=8192): if chunk: From a98afe15932a96f8a050e2777c21c2bdc1bc804a Mon Sep 17 00:00:00 2001 From: Russell Jurney Date: Tue, 15 Apr 2025 01:30:11 -0700 Subject: [PATCH 54/70] Add starter Pregel tutorial, reference in index.md dropdown and global.html root page. --- docs/_layouts/global.html | 1 + docs/index.md | 1 + docs/pregel-tutorial.md | 39 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 41 insertions(+) create mode 100644 docs/pregel-tutorial.md diff --git a/docs/_layouts/global.html b/docs/_layouts/global.html index 51bc021ea..4eb102c0f 100755 --- a/docs/_layouts/global.html +++ b/docs/_layouts/global.html @@ -75,6 +75,7 @@
  • Quick Start
  • GraphFrames User Guide
  • Network Motif Finding Tutorial
  • +
  • Pregel API Tutorial
  • diff --git a/docs/index.md b/docs/index.md index b9d8917bc..7211a61c5 100644 --- a/docs/index.md +++ b/docs/index.md @@ -64,6 +64,7 @@ GraphFrames supplied as a package. * [GraphFrames User Guide](user-guide.html): detailed overview of GraphFrames in all supported languages (Scala, Java, Python) * [Motif Finding Tutorial](motif-tutorial.html): learn to perform pattern recognition with GraphFrames using a technique called network motif finding over the knowledge graph for the `stackexchange.com` subdomain [data dump](https://archive.org/details/stackexchange) +* [Pregel API Tutorial](pregel-tutorial.html): learn to mega scale graph algorithms using GraphFrames' Pregel API **API Docs:** diff --git a/docs/pregel-tutorial.md b/docs/pregel-tutorial.md new file mode 100644 index 000000000..9aa1fe32c --- /dev/null +++ b/docs/pregel-tutorial.md @@ -0,0 +1,39 @@ +--- +layout: global +displayTitle: GraphFrames Pregel API Tutorial +title: Pregel API Tutorial +description: GraphFrames GRAPHFRAMES_VERSION Pregel API Tutorial - HOWTO scale up slow algorithms +--- + +This tutorial covers [GraphFrames' Pregel API](user-guide.html#pregel), a DataFrame implementation of the classic [Google Pregel paper](). + +* Table of contents (This text will be scraped.) + {:toc} + +

    What is Pregel?

    + +Pregel is an algorithm for large scale graph processing described in a 2013 landmark paper [Pregel: A System for Large-Scale Graph Processing](https://15799.courses.cs.cmu.edu/fall2013/static/papers/p135-malewicz.pdf) from Grzegorz Malewicz, Matthew H. Austern, Aart J. C. Bik, James C. Dehnert, Ilan Horn, Naty Leiser, and Grzegorz Czajkowski at Google. + +

    Tutorial Dataset

    + +As in the [Network Motif Tutorial](motif-tutorial.html#download-the-stack-exchange-dump-for-statsmeta), we will work with the [Stack Exchange Data Dump at the Internet Archive](https://archive.org/details/stackexchange) and use PySpark to build a property graph. For this part of the tutorial, please refer to the [motif finding tutorial](motif-tutorial.html#download-the-stack-exchange-dump-for-statsmeta) before moving on to the next section. + + +
    +
    + +
    +
    +
    + +
    +{% highlight python %} + +{% endhighlight %} +
    + +

    Combining Node Types

    + +

    Conclusion

    + +In this tutorial, we learned to use GraphFrames' Pregel API. From caf005fce7f1fbede3daffffa77fe7192c38b45a Mon Sep 17 00:00:00 2001 From: Russell Jurney Date: Wed, 16 Apr 2025 22:05:41 -0700 Subject: [PATCH 55/70] Added a pointer to Pregel in aggregateMessages API in User Guide --- docs/user-guide.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/user-guide.md b/docs/user-guide.md index 2ec964da3..286b7bca2 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -901,9 +901,10 @@ sameG = GraphFrame(sameV, sameE) -# Message passing via AggregateMessages +# Pregel Message passing via AggregateMessages + +Like GraphX, GraphFrames provides primitives for developing graph algorithms using [Pregel](https://15799.courses.cs.cmu.edu/fall2013/static/papers/p135-malewicz.pdf). -Like GraphX, GraphFrames provides primitives for developing graph algorithms. The two key components are: * `aggregateMessages`: Send messages between vertices, and aggregate messages for each vertex. From f3242fa96116d2c8153696564f002e89f2917764 Mon Sep 17 00:00:00 2001 From: Russell Jurney Date: Wed, 16 Apr 2025 22:22:03 -0700 Subject: [PATCH 56/70] feat: Fleshed out Pregel re: aggregateMessages, bulk synchronous parallel (BSP) --- docs/user-guide.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/user-guide.md b/docs/user-guide.md index 286b7bca2..6197b0fb6 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -903,7 +903,7 @@ sameG = GraphFrame(sameV, sameE) # Pregel Message passing via AggregateMessages -Like GraphX, GraphFrames provides primitives for developing graph algorithms using [Pregel](https://15799.courses.cs.cmu.edu/fall2013/static/papers/p135-malewicz.pdf). +Like GraphX, GraphFrames provides primitives for developing graph algorithms using [Pregel](https://15799.courses.cs.cmu.edu/fall2013/static/papers/p135-malewicz.pdf), a [bulk synchronous parallel](https://en.wikipedia.org/wiki/Bulk_synchronous_parallel) algorithm for distributed graph processing. See `Malewicz et al., Pregel: a system for large-scale graph processing ` for a detailed description of the Pregel algorithm. The two key components are: From 269009237f734f7c7d046bec8276d3c030a7aae0 Mon Sep 17 00:00:00 2001 From: Russell Jurney Date: Wed, 16 Apr 2025 22:42:19 -0700 Subject: [PATCH 57/70] feat: Fleshed out Pregel references --- docs/pregel-tutorial.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/pregel-tutorial.md b/docs/pregel-tutorial.md index 9aa1fe32c..874f9eafa 100644 --- a/docs/pregel-tutorial.md +++ b/docs/pregel-tutorial.md @@ -5,19 +5,20 @@ title: Pregel API Tutorial description: GraphFrames GRAPHFRAMES_VERSION Pregel API Tutorial - HOWTO scale up slow algorithms --- -This tutorial covers [GraphFrames' Pregel API](user-guide.html#pregel), a DataFrame implementation of the classic [Google Pregel paper](). +This tutorial covers GraphFrames' aggregateMessages API for developing graph algorithms using [Pregel](https://15799.courses.cs.cmu.edu/fall2013/static/papers/p135-malewicz.pdf), a [bulk synchronous parallel](https://en.wikipedia.org/wiki/Bulk_synchronous_parallel) algorithm for distributed graph processing. It teaches you how to write highly scalabe graph algorithms using Pregel. * Table of contents (This text will be scraped.) {:toc}

    What is Pregel?

    -Pregel is an algorithm for large scale graph processing described in a 2013 landmark paper [Pregel: A System for Large-Scale Graph Processing](https://15799.courses.cs.cmu.edu/fall2013/static/papers/p135-malewicz.pdf) from Grzegorz Malewicz, Matthew H. Austern, Aart J. C. Bik, James C. Dehnert, Ilan Horn, Naty Leiser, and Grzegorz Czajkowski at Google. +Pregel is a [bulk synchronous parallel](https://en.wikipedia.org/wiki/Bulk_synchronous_parallel) algorithm for large scale graph processing described in the landmark 2010 paper [Pregel: A System for Large-Scale Graph Processing](https://15799.courses.cs.cmu.edu/fall2013/static/papers/p135-malewicz.pdf) from Grzegorz Malewicz, Matthew H. Austern, Aart J. C. Bik, James C. Dehnert, Ilan Horn, Naty Leiser, and Grzegorz Czajkowski at Google.

    Tutorial Dataset

    -As in the [Network Motif Tutorial](motif-tutorial.html#download-the-stack-exchange-dump-for-statsmeta), we will work with the [Stack Exchange Data Dump at the Internet Archive](https://archive.org/details/stackexchange) and use PySpark to build a property graph. For this part of the tutorial, please refer to the [motif finding tutorial](motif-tutorial.html#download-the-stack-exchange-dump-for-statsmeta) before moving on to the next section. +As in the [Network Motif Tutorial](motif-tutorial.html#download-the-stack-exchange-dump-for-statsmeta), we will work with the [Stack Exchange Data Dump hosted at the Internet Archive](https://archive.org/details/stackexchange) using PySpark to build a property graph. To generate the knowledge graph for this tutorial, please refer to the [motif finding tutorial](motif-tutorial.html#download-the-stack-exchange-dump-for-statsmeta) before moving on to the next section. +

    Implementing PageRank with aggregateMesssages

    From 9b25035b9b273d01a1c1f3fb3acacf33ad7ab680 Mon Sep 17 00:00:00 2001 From: Russell Jurney Date: Thu, 17 Apr 2025 18:06:14 -0700 Subject: [PATCH 58/70] Ignore spark-warehouse folder --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 4d0a174e7..3d27f88c9 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,6 @@ python/graphframes/resources/* # tmp data for spark connect tmp/* + +# Table metadata +spark-warehouse From a612c6f477c3e88520257117321b1deb58e2cb52 Mon Sep 17 00:00:00 2001 From: Russell Jurney Date: Thu, 17 Apr 2025 18:06:45 -0700 Subject: [PATCH 59/70] feat: Mid-way through a Pregel tutorial. Implemented in-degree, now working on PageRank. --- docs/img/Pregel-Paper-Supersteps.png | Bin 0 -> 74735 bytes .../img/Pregel-Paper-Vertex-State-Machine.png | Bin 0 -> 30060 bytes docs/img/Simplified-PageRank-Calculation.jpg | Bin 0 -> 68037 bytes docs/pregel-tutorial.md | 114 ++++++++++++++++++ 4 files changed, 114 insertions(+) create mode 100644 docs/img/Pregel-Paper-Supersteps.png create mode 100644 docs/img/Pregel-Paper-Vertex-State-Machine.png create mode 100644 docs/img/Simplified-PageRank-Calculation.jpg diff --git a/docs/img/Pregel-Paper-Supersteps.png b/docs/img/Pregel-Paper-Supersteps.png new file mode 100644 index 0000000000000000000000000000000000000000..25d55f80894bf878d9fc119eeb5bd7b2b4da9899 GIT binary patch literal 74735 zcmeFZg;yNS6E=zl*9{QdA$V|?013g}-QC^Y9Rk5EfdqGVcbCOog1ZF$hP*$!_x=U< zoNv!AJ3BMo)7@2FT~*K1gv-f@p&;TRLO?*Eh<_1PfPjFi06r@4Z-6s7>NPYF5XjQz zA|i6)A|fPm4z?!dR>lwzU&51A;nWldv9mN4NMYbbg(QAUqsLZyBC$A*(&_H!H2%_nTZY}*Zo%iKwZ$xk} zgV*tt5aPGCS0NkDEDVWk0#hW)jj6PZjDEZa#2a`a=-&{MZ9T9Zk)9767X zdM)IjNj!~5WWdBo5KoU`o`+m9_ji@tQG^wu!@Ugyk(18(aVcbha5#(-HBx$`3F_i$ z43a9JYVINQ?_CDk7-a#RKMhpKa=OQg6Bh4K4vvT#9qYka9ZdPYC@2fJSmV>{te&j3vpHpJpv$z2ZX55 zAwx+`+(f7JVqtW)gh=lQ$uQz#qb`Y$u>7G0eDQi|xzFK$Hu3mHR36Tw3_ zp6M|EqQE^T!Qcysx+5k!*T?F3=b*2A^Wy1~^3-KV0utzXA7W}W8Ojju-veO)&m3kJ ziN7w;fJ1~@?o+|DWwmU`6~lBB)E^|%1>L1AjI*)pWFK$!^&3VG(g7UG4)xRFGxNdlo0af z^^nt@Os+@ZXEC@;WE1>i^%(VXuNsmX$~?&Q$Pg&HzxY&rnUo&SE5aj2H8wJ2YQK&n z(z7WJ6SJi@CNowB@xEQK2)tCB(s_#5W%bg<@COCr)DRjPTF`q#I2N*Ejj#<9)px^c z{%kt$O~*nvRB(w<@5T@kWE=NyyW4$eLp@Y0J-)FH6gNmgba>&9Z^PgQ_3*9)kjfl! zQ($-fdvjpE`YY@4|ANu%bdiI;@q^Ams0jeAo7xd+!KijxH+VF4I!?gp1fszQ#G&JL z30+};dN3=+GB7wc17XNEzR)K`{UA*ZgOMk>jHCR5TlJ<;w2riPz-Rk|0~B|t6B%dx z!xyv*>IA^Y{2WIr?UNJXoWmrg_0;%uP_GLdBCSX5=1F{INQpnkV+m z1mApMj|xvUKz)b5WgxGHnCw4T0jox%5~FO0qK4!(_N|h!1jjQJWdCNYi*OwgwG(6q zDFD*-W9p|n$7{h54A$*C-SWOOg^JeuQbFy8=@v8?B-KSPjF6jAk!TlYhj)^1(`88( zKR}@YTG`N%qA~&PVSWQ~f^IQmLA>E;;ZGEp;@L*B7gV3(?|yhGAefVzbMC*}$1F_0 zN-p`#LCTvLI6z~B&={sAQ$|iqsY@nE>YHPxKu?qP^?gJfOK4R>Rp69xqtJeVSB`~z zY2oUOn+1n6b8Wz)5WjS*h}{7#hnv&GwbH??AR7c=4=*PQ*IxBPB82f z+_QO*wr2Rm`S`CEi%-@}ZChNhejlU-I<7HN}rVzolM zLf)h;6=mML&Fq*;mkO5xPC4y=7rhaM5v|Tqpg)q&Y$wS!M* zSxix!uOh7?TY6pMJ{LT9J?Cm=#?i*XYF%VqHjin&P{&=DZq2lq>r&>T?xO61b0qJ{ z=~?+?@-Su}WKXn3u~pQs?JDQG&h?gShIiT3h$n;>o125X?VT;NfbrwOxoMxTi-Yr! z^Wuh+XTVL+1i~kf2G!%f)gHZFnL`^(JO{JcW>@$qNVjReIZUgrVcE{O;d29{v$f;X z_U;Llqov)UeZ8Hm8 z6z^OIH-(O&@naPbVdJRa-jFOaYGJQp`hO1kToT;?GRLI}w~Bz0Z~0X#@A|#;yXohb zw<1yDF@+KC1iYj!I!CEopO>#1ni|08&*yYow$$?!?=crK_rnXrYr}05pc3XNoTZ5b z-JiEF2j2{CZtuQTkT4h;`}$1duQ(^WFXJrDAl}L`>v3>YxL1HHHJs^U#N*B5d*8_> ztf8Qxy(s4MwmoevdrWfe>nim+A_sgH(o@i35I>piB;I5q@@W)1t}JUSE7;`5=#NQ~ z>2*Zoro{R1QQT4Kf~`ZIgUb%`Hr39_DEVIg4q>vHqyxoIlXv4O{NEKVm>A++Mz1m& zeN3|HrRo%827V5uUplYH`ZRZaK9An?@t6Im+_|J4UX407m&TB~BG7L$R>dug7>MYb z5;=}FVx4ZvV|(86aLIjH&me^TgUL+OtgXUL|2A*^6MJM~q$Bo=#Dc z=m;Dv!AGjQ^n1)`O!dL*d>zSppGaT2=Q_iM9%KA(-i%nu&kDOrDmpjzje38W)}-&s zHkz$;n`w)cFYn1Y@;RHy6Pqb+s7^Cy zUM|=|<6Znxw@di2l6otCn(dWx9e-81nRxs&d0apH6nPs3`O6=c=k;=kH($llks+R! zVIdfBA>QzDD*XI478(dEpJ@(wAmBSAWe)w`-> z-~@u*7j;Jn2yDvN52UyP#TiiloVk*klbW;?kD;wKqrQ=?fia_-wcTqy5PWVtz@fFV zlRk-?wUvz{j~hSP-#d7K5wUeJCgEWG!1#eo0Fi`*gwMgq zghxU2^S_D%zxc__oSf`Tw?DawadHvH)V>k2vKFP-MU)=&a$n<)J ziJ9>O(|_s)it@dl<&iUYGqzF}HMa)F40whBJ2w~K-~0bRSN{9N|0${V-;x|$|BsUY zbLIbCQrXeiLB!S?c%+lSf5r8$$Nulde--3odhPlDjKn{~{P!#{&H{*hO#hLY0HVp) zFd*!OATSq|RRX>NC3}5A(gAO;ULF23lBP#UocLyjA zq8RDpzkdSfNmOAdI(8473akIOG^U<2N|+z~f8N^>elYV|nT*aV|5H>5s-z4O1N}d5 ze=UqSi1gy={IQ7tDf-24mi>R)MHWW~g{kN0+%%*9KSd!CDw^K>x0ymP4xfaetjc7* z@zDLZOJpRFD&YUAIZUVw{)^w}?7o8Or~mX&2{)G9# zsA)t0Z;u;++BsBzN@imHr$3NDcZx0lr{@1VSpO2n{~fHqO7j0h&5GlO5_~)h#OJih zz!&ssdAJ7auC%{Aopv48ulXFFy%;4*?-pcvSiBr8RR8pSSvmFj*2r8a^3O~ee4XC} zlCu<0WO9)Hv4YR@t@j7TC#ChP4%t5Ug*ZwWDqAnUFHg6y{xzEQHf4A0gA|Ex+aC6R zW-oKDd2O{l-Dti%Uycj%25AJT>rBh=mZm$;scXMHKk>GhWVxGgp7(%MCk&7OF=cV= zfTgH*`8-36VTJ)k>~-e<`h@PoTCk2Ie1_vVe|@@TS>v)4>*%(}?FoNpv1+-F4tyyr zER+?x8HPps{Q=$WN%N_PKW;8}%f!(b$C8!KuT0Y{_hn2;`i*2Q2XW4aqiXO}Nesqc zahj0!q_UsLF;?LI*9$SE{i^$(GxQte_bt2WwgOBfCLb^V zESP3v1udxRxcBluT}_{)nr7K2$_luDO!<^U$YqxUep7$YINk7S*hpeu?d*9;;W$|w z3#65zSPa2_uyT+JOuNt1edEqI-PQJFZ5Q?Og$%R2aD@|d=h2rv!54e8a10ZS0e9a& zJKtI-S}%s^nt|Rgl~xVW>I{^YnH)3xosn@AK+DCal(fkvx?uXEaJOt4cd%XeGF_W+ zbiF2Xr3u;0X!U z0<^bE2C%<#<8k-YP&61q_}msV@0n^=Jq}A(#VuBx!Op&qTf{4fbS3MAKNJo$ibgks zT)^6{?)o>xFL$Bo#>wg}ADV&N-Sq`*HTIPBi|k*G{jYdn6~XJ=|A1lk399|^BGPl+ zAFgG>&sW5I#K%~7K{OE|GjMA)u&@yUq_%BndB{Rq3Fs#VXISQLrdg><=-}j68p?+ z+xKFf6r|f+9HAR=E*ODsAJ$$3f{6;nU)yG;4Je!D?Xrat*#r%wu#~prDErA~B$G=} z%OcS2l^EWW-{fzcJaRZ(#MgCS?!Vy+cs1ad*u!Z0K3%W4ELrcVXge#H=O0%=9sjf3 z0o5Q>WNuD=#xUcD5=9X*`y|;g!ZPfZi^b|MPMukE{U+TXqxE*x^Q5S_%;B4^PvhZg zYx9!pns;j|Imk`#h>1E z+qV7Bu|&tZFb4f^(9p~s1`^fHI3!aW?=~YJz)(o^>+R!vU#5@Q7xKeG@zmO1o*z#7 ziG7{CV*7n!iTGPk%=I#p=juqi{>j`NK9G{Wk1txnrJn@_}+4^x^-s)=T8ob%Uvu>Dnod$CPWnN zGrHgKXiZbt2pP%_>E_AUz5HIPFW0oH?B%me>DK-9>b*=+04u%py>neO_TIANxHB-| z^@i>&e0u0|KQkO8vL%T+5g{YtjNOF1fmyeDV-IN5b=EF?Bkc}C2YP4*5>v|r|DWx| zGQixx=9-Uj?_Hs}S>d~{U#_D;F?Qjskqe;`^343CW>}M8(KLJL0m(YKF58E?4lUZY zKRM(thq=79*-!a8$~w*mJI6Z4^&KYzUb&b3>Cav^4vdyUCuXekFhjkQ7TEj# zO8kvkqJ{qQUWQ`~IQcuN;7P+4p3f?}l6m60cCC8R486@@zr|`(+41|em*@KSvQ+fo zhkiiee`9Mq$=3r`wvnN3URZrF%_;g9E(W?k85H*YCrU9GsV@TEB})DPGU4eJ5%<|=vLV+DAs@^>R*ZNCvR=y^NcZG?J`owZ#} zNq)EA>QmC#rQ7uNIfLfc5aFfN!9nl_wk})mv;IU{jb>kYvD8Zy6JX z$rU4Vr`XgV4;*Ouk%;rx~c;SsuT$75tfL@tbf#%`~_JFLo1{ms~VH8tr63H=CDmxdtRD1qU_aHU~n zZ}W5=>R0l2c^oAH3b$KT&`}Nl6`@>ceu#7N$ z7abKeJnty2Ss%JE24fSZ!Q@O@cQm+1pT%!qNCye(?x=d4s8VXRBp{lz06&FpTRAKk z%S0e0(yHyxZan3eGbs99!F;QE3?0D6|1s-E;f(Tm{2*1}$6h{aUg<>lF>JKDuKa4?`If;Yh|g>qT}Ol|06j8LRZH>a zX316zm`yHhhb7gY0qbix&tHG~Gs~k0+)_Z=^2Y3ASZ@JeIjr>)u4%>wH9LHH3w@26 z5lz5TTtk+MrZs!HyR?WKidG;xz7$c{tq0COlkkh5bbvvZ^AX_H#P`T{z>d4$p)L7-}jDyLXg4Yo|+1%c1TYNH5gWdc$pd#FXQIO6YY zQplMhtAD%=2bUrOA>?AZA13bnilAg~4Yoid1mympPofJmue9koQRXLVJM3|`;}eR- z=T11{BM>NsvxS_?XR(*9OhD)nSiuhhud-cN-N)F@&0UL6ZV*CjQNP$sOLIC?#eubT zF|o8<7K~2-JLt;Pc9)lK5J8jhtBS^XUbJ5q-^5^>-XYL8_{E(X0m|H**yDGu5{pMt zfS&%nmqAb@Q(MD~i8uADr^gOxi6*G7k3jTZb;?`A0VUwm&!c(+Nw?I01guCLcIphCo@tv z(^5SNp$d0Qni^qJ?Baa(F{2q65SQ0n8Jo33B40n(L;UX_81_1c$9uD?y2ss+bDFYq zG1T#*1Ad&-QmlV0y#ZhGR{t&xTev_p=?HV(#}Y9uAzZj>$?}>WJr2c+B>sX|@CWx^ z1}lrS@(@4G3g1E$x@a2i{VylkgO?jX%`R_J@~iM?bv>Wh*DF39t*N}{ve^hro(;Ht z{s>3-b@oJgmWj%^ptJlS#pF0jPI~cxui%!Ue?ZDBZ|&*CbyD~F;#*iaUhvzoo}-hM z=K6ZYw#zZzVZzmfHIU%ahy#lt#~4oglYsZVDBvknWDl@l3~Ew!j|AG-!BKW_ytB)n zC2t_>AR92LMaE_6F@+hnc%ePUfjo9MuB{pB&a!OueR-Z-ab3$)o|*hT`TaW(JqeI4 zX=foNvS7j+IV-%;tny1Gbjo7JxtEEuVhs(K;o1Mu<+?$PDdluH$H1Z(Q0J&a9!aWI zm7%vY%DHxh!8cOhEz8AaYukFQBKoDNv~H0$<8$ktAK*SmWl+>8nH3+)S2?^wbno@EI}D)c8&w=$2R>T!WX;+yC(1!Y;{W}aj?J~R)aJMp(Q`IAA9F9HCyt8()izLPR=#R>9d%!t{=sd4u)ACYyvL z{E)Pi=Vsn8R+QQ}MfrpNrv_iZrvTw%?^TJ#5@8hJyE9RwY}AkO*DH}CZvuX}j)mf| zPk$>`XJ%3peOe_%RyZvl7}q34KVX|!^f}e*V$e}A)p`M`q^AQg=YCU1<>>T(HbCF2 z=DJVQC8ZEwjTzPu)=7eaI=}_>IrG`*7OcK4(+lvkZi>2Oy}KB@Bo5 z@`^6REIKG=8aMIC2ogw3DTpEdWm)b1M>P zGup=#)~IG>$PY=8@vU6u^+T&h_4>!I7iEHzup5jhVT@>Q0AH@*)R5){PD=2%pn03} z#YZ4GdowouOa6Nv_ioDfvaIzh5D^#l=bv&ob;TQ=tCOg6iW5T_ZkyrPO7RDkMgc`& zF9=@FDfcc=e{{nrPTG9O!yt8biDk+BB*p`4x$)#DoYDm^h*4TAeeM;fY3GTQ%N(Or z7CS0s3w~JE0Ckhp{481pe(X`IO8rA|4Iu-#l)Tig__qui>*+rrB5^wbcRqu*+ylRT zD~6XZ9dK?Nt(1+%hO73a7Gl7a{+3~uRK??P1{l>)9nLZZ!<#HiR@Hx=+ku(Wn|{Up z9axY|)_c_Ee>8oItOw%8E@;y!aq8oWAkdVwgJY#zhI8~g>iB5R)rM&vH38~KrD~)) zX>IeZ3Dh{*wk%<`kTW|bm!_>ZxOLI*nDeqsUL6&&Z#o|4+Ri_mKS(-jh1QnQ#@&(F zZSxDz7)s3-1t0UprTm)JfWM1{80h&?Eu5XEy?)n+Z|k&3A0sSDUn%6kPh(ye_5n!%8YJdV{4rMc=>YU?OXT>(X6DO?T9{ zuYZz2H4iKd9HdXQl`^ZFH$bwjS2v@$L5oVIm>`Nr|3DXsLuF-Tq3QEyhrH^?0yGZLXxIHmq_fa_XV zx48yODO+eta*H-by~iHK;8XHEP6c>dxLiy?$ZYL&F-Q%b1x41XyRG*So4orO3WsrO zCU;09%YUuZBUP)k(Ko_1AvI%Zt6B71*0$Bxjgrikz5}DqCUp%@rk{$OY*rZY?QT*a zCv&G$zNNJI8ON3y1`REM4?D8@kl3vD=IPZu8IJTt{H9smSsxm|tQ|0?4+GH4 z534{938AUOhz>r1K+&swFRS;~9)v!kVR!JfwD!DPuP@v!0dvct;AxmA8d817|EBIe zPRfM6bdIKV@oaPpb2wv!YH|4ewj6;@;W$%Er;gs<11u)S^uon~u7A}PIx`vV3vbaEIq29eV z=MnVd(+c2?BJKHK%9l6x0VxPBe=%+}JvN0IS&6k8L@*uHn*ec|kJuZV0Z5ErYCNMc z_4X-pBQLD*y5Fhk3xn`ZU^gSFABM9|>c%*`01@1+{weT{D^c zs~>N!b}?r>0KpR{0d}V2827X1Q4ft4IegxcByX;sig0#7;D`1?z_FV=ja}hv~qTO`wm6$xN9$M)|u!{?xNF?}>tS(tXUH@|Vs>APAPK zKw7l9kG)a*_jqoMYMerI2_V}fk9&yht_%B=h1KV!lN)@ z%2A_tkD!NF?wHnpH7q*}W2qpkqEr^TD<3i%TwO* zk<~XwA!Ube&~Mj!(V#7P_n`Euc!Z0m2Za5GeM6$88R>V-DOeSRifBoX=yJ5`%@)&) zF5QlGFbC5ERFgQ7t@~0U6S+lz3pciaGLw2!(|ud(^+A7rL3$~*4!mcK<~(1OjAZ_{ z+ts`^oufOIs$rsq|1opLC==mYV8>8jB5@v~h&g`(i^0yixLa-6rFX)+(jmxF`2UzSK2toVB~ z9x@g>8^OB1EBrtjCb-DTecpb|+CbrsvDEjd^Kj%be}d1jUOb>0u6+pzB9`CaTglC< zoC=;FW*nw@iYfF`8IQ3Aj3rkj7O{Y??N;LYz%XL{wqP<0u5@JX6O3iJ5t?b+>U<|t z4fQos%`q1$wm&#vlHQu&F&IW)0qermHPr@RTOi=_;q@J%noXr{T>{iObD^9EiFuFy zric{;WeT$Njb({0W$m2Sb{M1_BOrXLjODuslg+fIQ+&=cEO*>_p7d~-^pPCk!K0n&6 zeqpeQX*eIf7wrCPHnvkr6Pq_e@H22%sHgB99MTQ_ecvrvtqHCd&ezbMWO<&P|5-aU zA4vX@F*@a~O`TyEz?ulFOBiqrVRlHL<{CvHr8LKpVbQpYXE_rt3&Ovvz15B$D#@7I(Zu(FtFB@GqIJusZsy##3lVD?+_u}_T#1@b-@0Uh{=0c=pt@8PqS9~kITSy9Sfv_q_0 zF8PY;e3PPUbc1JLZ7bWIsE(iJ;NAfs~5b3o{tfSXo-<-`l;ET zAC@X8sp{BYKwafiUABpaQub3jq%~sgmFA{aJ@m1mxtju?w0Ab!RhHT%`d5tm4S8Y2 z8i7PA&CRjz3ndH8)3-kzFlH<-!B%>#b`f_Q;0K7!-6kM$`>n>2|870-3GFK}O*xR@ zYuc5+TEc7jPfp-p}8@3KK%63);*pn)&W7fy!#Sf|_W6F$!lo)YWp z>nT>VLvhCNCz%%xIX&>U{R$Du8fSjM121%aQ>)%jzCLlg>8Wd$9nMSi{2n<1DPF&U zxaU|>TJo?R>86}ZIrVd`of-Ut5`lh~6x5@AP*+~cXRt3Lr~dY8RJ(8s%mY|&32&|M zh09YP2hYm1+vUg|SIq0;T^3Wp!d0_A{zytdg_`vXEurggMat7V%n4gBkxf`$Ln|-t z@*TCFTky-mD_#t}*KOrU#wJvYy(!AVx(hcnob1Y@WPB4AY;7erP*iKDC_JoDW>>xa zM#<~@`+8p>MD+Jd_nc@*F2ZMk#S7Fxf^t|NDQRoyY2BJs>iJIc_H=31lnr_#cVf0p zeMyGVi3qXNvoY)WT#po*fG?2K`B&__{0uZIyX&_EKQ<9)A`XS)D1XRTBPBBS5r7-8 zZ~ZNaAo&KT-a{s@y{GZNR6`$Xm>U9&3Uys*obowyNQI{TvGv(qKLRoXHEPZoLa?0j zj95sG96Z*55h99J*f#p+6z0#`hIMaSDjA(djJl+0yO86SMDeJ)$Rt9kJiDN1q#pRG zBm9;JjIM=HX8EFH-TbYaB-J}=qe6OA$G&a9w#z7`LE^t2sHsWdmSZb;F=;a;#bkah zw!`;I>vK06JtI zH}_=Dbp!Hc&3Gn1m(+`p(R_(G=-n?268ZvJ_{=a;EE$M>?sCWdvkLYlOTG8F*lOU{ z#gjiHRJe^N!c*A7c1{zdKHB12TU3Ta6iB2%zolM=-$D*h_#*L9YQrwO<2!L8e%YlP zf;{zya3F!(lAZG1*qBU}00(yiPcf*$FGOfCC^IR}&F)z*1~ZSmX9@YG`XVq@#tSNj z3X3$otq%zU^BvL#zBJzB4(8jw0BFI`l?G=fggU6agIzy=*rzX`R-`!*GV;PZVJ)6>g-9Kzw1$gw#i0=>l)GwS9qMZh$&RXv5@EFq8)e7X`S zU@eYI>B30l3|G!8TLjScikE3 zz2_{tBiD&=@}R&9r`Hxuf1>pSv-X4wD#l=bmGy;0GY5#dG1I`83{qgVmG`S;KfGR15cfgREJ)$#Ce#IsxJ`!r=kQcU-qP*2U zvBjS4I32dnM_e?_LP>gSb%$%IdXwAJYC>QiZC!ZZe4typ#W<^S?wYbykr!M|B~BK= z(c&iPJ%;u=O}JID$*8N;yxDI<)F}jqatM*ph*#Bb^izLi9INCYOxJ?z-|BM*=1YO^u{0XePvFR<8R7uL zU;cCc)4mnLaH`5^KTFBavYJ@R5YjCYGv`)V#`4yiG;*#+b7XI!bB{v`c_?Q8@N#^p zEU>1jMn4E5J0-8qDnkW_#~OUppPAoz#T?DM!n}nqow(<_XR@^yhLP55FsYIueeY$p zMSsIY=e?OFysU%?O4*&J78;OP!&``TA#^2At@BvH)MIrD87wm75EbZF4r$tHHlRWC zR(a1V{Y&$mjHi{l@BAqcu#UBOWikxAlg`yNmfZ#GNlrKf%$t%*Dm`W_TKHAh^a?1( zG2k};%=TG?gCExr)HGLk^FkiWH>KWWG%nyHP<7@@Cq0BWnE2z3kpMYqlzC4kH!(M6 zMO@4GB+2HO=A?0R);_9Ne1^`do3GBh;JxO-?c!HNG|M|1li_{(-NGdRGS>X;Da?Ug zt$nF(cQ7kM?GE*&-9l$<3~KKoO5bR~-}k-I0%uzzRTI{Z{=|GsO z!=-rsvW{eGmCF#?M4ZllhE$isbzjY&RXWa~_zQl((`*yUcF^@B(qH}akoiY$&0%3*V8 ztnrM$Gcpkl9W9Ukt%1~{gR`hxq6!_n64%dl{fuA-Ac^5gjcb6_r)aH#>de%W0E>*K zA-s+L}DW z(?SdUIh8c5biJL|ER*TS^;T*RJePbRzw`BWtPa_{jme$}n{(sa{{DQ-PD{h4I6rXW5Sd<+OkP!^LpR+m2UB#3P3MYzJv(;r0nSnRvoTh#YAPy%$#Ejo+oM9 z?9s!U9c$XBhy774B{Wu3WqjKm-#>ze$ONO%RfI7Mv9ZiD$)!1U9T3d$hgb6VdKd4Q z0RTe6@&+}Z3A;(tX5fH=0g!PppR}hg@(hm=b-LpoLB2Y8zTwP*4t9*~CRg$w0ICNd z5K1E(8`2^Q(`!sPgSG3KqkAlBrD&dWLno$CyDZ;%pc|~Y?-82rV`fqY!KdTh2zcGu znHC~zQ+Cm1R`BiVKsWDYd8({X2Pwg*>qyDLPZ};HBQgacYr32$NNPHFQ=-BP(+AM& z>?a@P^ymzF2VH`WRs$WBdzWL$De~b zu0}fGKr#!${L^5tHCjm6+w|!7^Y0Tl@zW(hUt6o?$QK74GT{iF;#{S=%u&YgKYt1U zXc$anNN2?A6%Mvr8b}}oUQ}Ebif{3x4RZq_CL6V6k~J9o7X#$AB)@*YQ*brtmv(~7 z7~pYaEB@7$aU7ILymhkH-ag)Eniv`qy?!ywbX!;dL&=773?;BA0szT|Fyla~>TC;( z=s{U+-VcAFK%?Q-aOa8S$|vY7ws=ul_x##xR3}tyM5VAIJ{I2Lm zy&Q|vXJN6J`Sqh{T$=ww2>Y1t2P!g%Kf8%edgM}giL0MwKRZBc69UvtGelrTmF~>Y zBCk6lz>qOdRyR4%+S^Vq&p(3@F*Jw~i08h$Ym#uU_;z2Gd{gBmRmi-#Z<`wZ2u*xU z7+_jTnu8t@p1<@{mYDb%G1?;m)*1&-JMXhm>sJG78prQIu8xl-@y*LK0OW!Bkf{jF z3P$NWEvF@eap`75)9^=r`!$7>a3V4d}*@W0km5OK1u;MVA$bqtCk*ZJMUPQy|(%~yWm<#e=Mu&Re!7&(Pn2&veRyfEQLhryyss9bOd9+AS2+Y$;s zu%>P6PV6vuflRaR{yPM7X}sA~leEHV0gA?W zaX53$>UUHJK%Rk-TRtpyjqQhk7Xb!dm$UUEKQUsICEmuj@L=?RS#kJ0gG-7nb?n?YDjB?x{9O=ZF=GmqRq z9EoY1ZmN}kWg6j$12_%&et{e(6rC^>+J_n{conyl#PrqVdy5l{0QF*X^#ekXy`uG> zB_u_7ZN9VfUt)3kAuFD&9YKADB`#%y3W`j|W$sn8$=SJIktbT1muO6Ggov1s$ zD+y1VDA#SLwb*ZtW9LB>?pMKoC|tEnGb30QMEBvgHEV73P5BcE#QOVqOYXCb zuL`FOc-xw=B`zyTaIMCk7aRE_jfIpSDr&|rsCO|+wMNK+?Ivm-lQSXaH|_I_&EI2q zXln(&TBJ}qjTpJvCIxW&ahJL$`l;Y1_5tfR2~<8{Lm57OT9X#?%kNW}?x@*JiI63L z;=$9ml9;9~K|Jd!p_B~9LQEde6o+0y1v@2|kzNA}90@9E1bzwdMWZAi22LNIYw2b|%<+gn$Y6GvoM0_SX>>;pgk5p4KR0qb)P+Go{MDUu zHZUnXfrxQwb+3JQ5;WtL?R{gPyzq0EC(W5eLv>R{V{uBV@3S>_sKYTj^xJ9lP5(Jf z`pcwhw#JwrQ0`JbAMYw(sarTYk3e>&5}((lgxuUe;oC4?_&3w_^aM9`(-GNh}%aG>U<=onvOb%s$j6V?qWHswH#AQ7Oe$-M{ ztIo06z?%S6;D%GpDMOpt`+@lo?1VGT+4=I#FEFP75MNC>u|6wHI|m8<#>j@xtX|wV ziZ%bACeXb$!DAPM$B8O`Sl-^o3s6g%q%hLt60uYR`L|wq7O!;BWwQu6A}wgwsHF-6 zANj$a?I}qnXJ91MheciW+_O@O;E3GxpvA!|*8tCC=VsYaR_s6v>o2KO6;QFI>k-Rk zvI$tosV&B;0kYSO>t9qB$J4Ug*ggiX;do;yjZKyo`0HIU|f1HfkOUfF#Bo;fOEWxc9e;-uwyT#I^a zsdF@zE+)nh4vaPVYe(JY@Y!|&z;pciV0PHL<-8BYBDcx{AnjOWH#q^chVmnbV}J_; zqs(^|{u_M=U@o5k_(XYf^obt>y*Tfefe!$iI!K@M$$PoMel=qwFOqS{xGJ~+S}_oq zg$%NQ$_-~5pvb`_kOE9D;s#)G_BiH1V4%u$nlhqQVE}G=eale-jZuc0KG6o<{r(>N zmjw)(4Q;wWJkPqO6Ps(o@g;1U0 zh(7Pd(RBiH?gdL`-=ov!3h*#rIX!%Uc}Wkg{#q@Nuo3H5{{Xuj1PF2V}*UL3u3?!y>i*RZ+%1+xNK<$AWcP+FAPV?v$Khz#>`aCZ^Wr zqKAZYrI~#OXi5hmPH3HgIhDESQY^NFz=x`ckT`9B@d;G~6&h>>|AtxfzRgTGNi!#~ zH}t*Vr{?T^tJvdeUif0$e9Uo5(J)Ar&Ss^hH5~|t=7L0DA=JGn*ZVr0ai)NZU3%R( zOxyxtac~RSS#TnJsEvXOF4H#IjssI3UskRaU65Y+@2oR^G8ftUY zC9?pFdZL0<2f%@CYG+k0WgJl~MX~-8c4H8L?Jehnir`NqkuWJrInnuXF}3oGxAZaE z-mhZ~xI3tTFh@6*Z%Q*f4hn5<7EE37Z5s5+=*t>+@i=`R-Rc1hm{K8|mPS@DmjA}C zXNHnamEHJgzVg|t9e~ytaJ{mBL-Z56W{^kQU=G5fKQ?BCb4v=N|D8Vn=Ei+zK)`&* zZt*o1)ek*glvLy`)DhrhW{El%eFs4SoTs1oY-VxAy~r?2YGhb)6{=bKA-Fu|G`i_w zZ4l`{gthdOKX~1p%~bpB9Mb=)2S)8!0#MfGEca+ln6xi`gME*ep8vI2j4tSfu5|IX?} zX%6*ZG3PdX1ax61s65=O{pp{V&<<5e?|MX$0kvbbkSO#7XtfPu#NIQRKKJ|FxRp&b zue0J`X*siEB6I_Dzz?!n@9<}j##apL#nzNEm$J-L{qqst7#P76PQqW0zlLspoCITQ z3}^$?CYugF$RxtA=dD?8n+WwY3X+ZDRH;7>vNnI;0F+jDDrMplXrMcyInsbMt*1fR z!TKQ9nyLBH*`xH}%AH0u_!Mz2O6NX^hsm6Ur}aQIuZCX|<-ubIqg{0p<+BpE!(Aye zaIeB1zypM>&P#4RMa)(SP!xgy{+Vi_E}*#D^__3<8(y;&Kk2POqJ_wy*n1oux6Vvp zcZIa2?^ZPD4UGnX&OH^>F8m6K;lEJ>muhmlPJD!OUg$ANx8AK=Fu|_}tc`19xyvgl z5)$M07eGW}ME5hIP(kWxz@`aWi^?=_x*|E=B#JX2dc8M-eY(GAHIE((w_rGW)#v*YO}b69neUOTX|-zNp{MHh}ia>dE-& zbvtk@Dd9;%bUFi3zO5BG){M-eewWQLz#PXq$~@NIl(9U|N`4Rk_JzKZ27#u%hU;U0 z%q&6hM>W$jtB@w4gf71)@Fo%Av3++_O20TaM zbB)yO{~D~kxje2#jzF;V9YY_>TWAdhtdcq0bF?8Rvc zFz7npfSoPbgv9`lD&J*r#Pu~1z`L7b=yfnR;#3q66GxPcY$Otj>Wn7%SRsq0*{Kiw ztDV1{Mgqjs4Dn-hl+rH{iPFf*!shT8tf4~~G4Q(%*txL183fz6!H<)vLjAhmj%4c< z-+Y9rRlHRr0~XcBr?{<`dQhNA6xK(8l;7s?N_}EYlWd6wHqZ=M1K8||KOE73*Jlo4 zuv^s2(7H znv{9xyU9U};V`3XdA<$L+7R{`pjHJ{yFc?{1+OGJ=8II~O*21eQ{J^|P)=9bjD;=fu-9Y$H}mL|v@+hH9* z-Lmkg9sZKx3xdTH2oO9_uA3zr=?WE&n|5=PaqP6TfX@LON60-k+|kd%_pPPo00f>{ z5G}m1II~HP&Gvl+Wk-OS%^;}+Ps^?WbkQHhmN-u{T(o;YMsj5?Yku#IUbjAO)iB)rB4$i)ta*Ogt~( zLOFmJXn$Q{;{*Nu7zUVLZI|vyKT+thC%Awc0jP4>1vodkhSCk|qD8BJk^5($hKcS_cpVAhO1Sr+jmf?G{81gB;~W%QWxC zg_^@!Q2@R@0-o9j;6u98n2H`Z|5*Y!p5O5RR++Tl)olu_vU5>!rNXewZ!u_1tr)?K z7kBM!i@|frk)pIOfq&=%;QNw`0u3%O8JN(tqqx97a(3-Jn_C7Cn#l`MUbsJjxIRUt zjWz~~bsk&ux@qjqClYmYCSlh|m_H8l>>ub)0KEHSRlEXu8624K%`iE*W<|CSxF?lx zTT5@efy3a?9fEzt{`p#Q*`)yPaWv-OLHgGR7G`$tK7edj;{)V`VDt+KDSMqci-P}Z!wwcQxx4P%SLWDw#;0QahR(z|Fr z#G8$`rs=R&pC{P*F%+{Bgywwo&9)s(E(ZxKXDx*G>xrLrox`%iVdgP(LdD=;NC49z zqawxZ`o#F{Fwg9VL6#~d$>3ng5kSk>#Sqwv3aX8r{`vX#RF;xrFPv+{QJ&4x(xrKL zZx(PF4zu`&IJ(dR835l%+6_4O+GP7Rj&I{0){5nBSAdmY)=E0a`pE!c=Yjv31a6-< zX{~l{_oYooXNgf3DNemXu7B??SKWZq=cz>;MY52=@LI*^(-0GMuL{u6cT?mlgoqny>hi%nY3#@}1 z$U8Mr2zySw(h$L*Z(*ZO4-rbA(1C*tEUA!tIRwWcSyT&M7>CEzHOY$LeO{qu^dI{H z#;};W(~^&5__GNDnsUd^c=-rJTR=zs`T+F&ZhUo={L;{{!eJ4G@A~8I=E&tyLqS>r9LSVsCfp{*%$C48*zS!W-7$hp?fS#X^3~}kIp=Gz zSf2?E>9CQ~uG9F7`Snga?Q*Wj**|e7+}&JW?&>k4M@v71r2ERF^nHJx;1q>s+#f}q z!^`+@8NBObw#MHy?^0u}dJ2`GLA{4=FAP;Zf+pb1iZ%gK*{nHzK|5I8?d=q2mD!e$ zmn<8FMND7n7=TK}Z%7M3)lufz4H$#bYvJLSMAYA;2%2@iU_N{X(c6l4gWK^+=@P0h zo^REEX|%LkQxJ)^yp8SCP7??le;OqNKzIBqBT^Mo^}6&1q9LxTkP{+{X>Yg_DqH>A z;NuNSos|cWsaysbKir2#>Ich1zL_ZTLUri)=0|~jj3RDbHv#XS&yX0J|AG13J2_ZH z`$eZv_!HJWkT=-dJTplPEC1;RBAX^sj<-IlH-my@4ckeB%D~3Op$S)bx8DD=4W=4I z9k#xr>>z1{xC*03wZAjN8h^#>j{vrp5`NtsfYn~%hnPkR*KU~FEOTPcN|cyRIzh%t z4cfwSY7-*n$ZO}SN<-O`-SPJ2KheJ%xF(WnmcvGzy>{tzQ!4KO-S{tk?U*&eu9UV~Kv)gR z?C2%r3fC4uL=Vh?rm6>NJjB2JF-(l6R|l|XtHOXC-JsjqJ&uqp6Hp;!jYR3b2J3cv z+9$Y@tc5i-_LAn25iFt(qoP1+SME!jE-GQj9N%{Pi@j7eP2}sP0!k&zZ;5DcEy@7~&{G5MggtapG%k)8C{$w}8d zq_2pT!qOz7fCid;KOC$+nXj_MNZ#ovpro&4%!9ft^Gk3?y!86Mh3MS*MTwT^{P#HR3(M@^;0%VdaBIXU zn^m%fabrQ=abW9wHcKUc**F-8?iJg>XBGv?4_nc)G6XU#FRJ{0gJj7UyG|^?=Xg-4 zOze*dk+M)3%NW_PE$ou?E|0T#@$+|ibM14H zAmAX*E^@Ab!dk%x;t28uZEmX;&+{|y_C<1ggBU$E8kux_sl|U?n4(wCgVzy@wz+WW zp%F>xN5F}~y98bfo}PU#kNqg}<(MRMc@_x9U?M#wY&^W*RGGxJZ>UyTO4xD4s#J*2v7&;IkM`O2= zUO7a`ht;ZW7?x%HlOuYfaA{jEa0VKs_O;C)eK35uiT2vDZ}26{U}GN%AKHgbtbg;% z#b%5Fd9C5aD(yYr$h###R6Vel=3B6$r5tJ*hh0eae$LiNl|&u}{6ULw9gp(YP<_o0k;bKc8WI7(5<-6KSK#oz)M|Jc8&7U1Sh zQBwlghB>t6%!Uu$e{5IHS(X=m#mQ@LhP zQjR4h@Z~?B9WG-prQ|Jg87wtqCuuvmd)e}hj7Y~`9Gm(O}>@ z-*Xv`NQ2215Tqu$cy__DnK2Y|2&im#U5SX?6{3`6Hi0S`yCRoys& zmk863Sz;Bos9HCKFcXQ&){NxO>#)RM2Z*svfH4$bg4!CO*p=;vj2S+}Fn9Tc%_~=g z!4s*@(VqtwwyI*-fG(zzsv2nYP`N>o=h3%2zTydpE?&Oe9UskIiliW_Xjbn;qzuI_W>8Pj+BclQaWT-v`PenmKK_fMlRMHL7@wnnI7?1Ej4n)*I+IgW6ke z2w3!;vp-sCIdJ(`Qv#aTOcZ~O3?VRB6-_FrugE=L02IQ(#Hw`FZ(=B~bz-T~m!!gw zNs2HWw;LHKm=|P6=o2e9{6w)~0WEzTBWsK7sBynk%)i#GZIh!>wB^=hm z#}t?J8RY+JJzkti3RKLsXnV4=@CO}uDn(i*z!Jd2su9iA(A}NwXusq@qC|HFMPXGXPx*bfank@8gsY_{h8ckB%O27*nK|T6a zuXWSH>LzQM;v@sF&*c_6RNZB?iS*9{`^*|zvt7R z>$fYqd$$0Vkc?t|VjJX|02@E>F6IicHh_W4-c>5?Q7j{!7~V``Ow-%VKB9u#{h*@H zQ#K78k1}JAQQf`XTKtd7VPSjU9c=fVL?Szx!kntOf2w;~x)z;>N9C9d;go;uE?xuq z*HW7T%8wRbD|AYaz@_5?GG!%9A2o$d^DlMcZ*?1cu~BEB%vOt5ybz>ia~aOTwYCI~ zU#KiGi*vdM!ryxm)khp! zpI?%6+3J2fcL|uBHY@YUzJe{5r+m9-WB*K z-`9XPy( zGnVcb7$c(Vby(fQOMzu6i7*0O5hoA9qT=q-QFgsseAR=3%&67O)VwOv4ljzP>XF?qiE%4r>>=^vi*AmMl6v^0HwrlqOM?YNmSvPK) z`5H*x=XYHQc~k8@DF!U}CwWxeIDw|XXJ6l6YQcTwE1xTX^shCmr&q~OrxMSE)R{Gh zy7?c*TpCrk-oo~qKqu6@S%^tr3sq>=t~&@^AfHY#aCoYjAWJL+5|8ozO?}{~Ux6io zoIKG6lxdWwn+d9rtzLH0)2yZa_f=XSEWrFQ6J|Y5WvjyEX5tpdW*`R{8lFZTFHv8}xO|gS$ z>KsSu%H2*)UdjjIsV~D}r$0_dSM$#Di$RuHS2rB{RS6akSsIgI96HfM6XWsbL>Q9S zL&)WBLK`8pdYu^a&ATmcU3&0eXGhG}VVWK^g(fF`_hS{B4GH}Deohj1miP}L10p|y zItOXFn(hkSQ~jxG)Y`w@N(_r{ipu|eO(#B_Aq@7lb9~|@P+W5HpT?wXp)1zx7^9JqPC#<9|&Z86|TQi2`XSdZHNYX?@`Q@AE@T8s$_)2U^>sWe< zX)fVNTR(tLg7!n);V-DoMtp`bA8tzdmTMNc0XvWKG=gfdgr(1-K<7X;P5g`bk@lFo zK8sjd4)Tqm9dDK9RSWjOI^=jK?=mai9Tu`fViw*qB1%Xgk^Z&R_1&H;jS7c#^%knK zecgBjXF**z@mDO9E97*i?U%B+BZAR1KLsp#8-bwmd$?lm?P<>1vsbk%sSr3-WnA63 z$znd_#Z=LQYv7phTvw%+E?bG^_Dh3Y$+<0K{cwLxM^C0I+vi65FrUhXsh2HRP_2zQ z1N&oSI7aXenPZ6=zIw1{k4odpeVHr9SEs)`UR@RZVJ?ko#7@i5mTkv?QDv_3hx(A? zkgApQVmjWvGuaTrY@YMRC2z6{gqqSwqx^2hQ>58Uds_S^e=7_uRCo$|itE*Q7W&UZ z)6g7=^9sQ~_vLlfrANR&8ZN%0#q7Y>*6F}*7dgiK$bA{l`tuOPZp7C)E(Zv)M5Rw- zUT-yUFwrs`08a9YWFL6aIS){Y@slI<*t!RRp{r*Ea~BjKeN7(Z!{lq;M?09)mDllN z*kx|zCnd{aJrv2Ul{?ABDFH-aMXEide}nEE0BUvOF$?j*oqr5Mz= zg6r=!Wuo&I`{U_XZ{#wqC33$7(sTxHl#|6iQJVbq#Wgc}E^ekEV=2=5Y&|@|YYnaT zpmOk^c@L;KH*fz#77tjHy~uASP~6#LG^dsQ`a$^PMX@!J*+l2~&xth85^ml=A)@i^ z3aPMkqsS7*HgqMPF+6r4YQuBRiHewkPS*fyfqfn?vn*O3xSayUx1}F1kIr4WrZ+28 zWfzC>~&p5(EkNQb;N7?ginz=hgUz+ABp+l`^`W z5Og9>CMRl%zmI>3uuhuC8c?`q_sXWNw6*I~JQ6Ha`Ei?|8p7x8R3bU4mYtMhs8YwQ zE8VjYJdfce%HS`1D&i>7^-8hB?y0?Kg=gsePdc;`?h0i|>ScE7vAs|YuZ>W~e|wb{ znB=`W12^ryPg)opK{c_|>@wzt``^8IR`CmRkX2nNC^KVxe?o7eIj0ySU7FJAE00|U zP+-5_N)3F7c3*$?l!M1qB4N4`Bq^_M_JT+qe!qT_(EUCw4&6?BROkAWybtSWuC@Ny zO2(aWLji?7&Ip{isVv&7jbL?3@kL3panE;bNS~*Ah6N*kfvE5=9G0C8E5BZarVJ4+ z1I5tF_?~*X8)~g4U1ob|R=fn7VKsk3{FX6O=@gMXA!;N#?_B!Swo04nHGT#fItxaG z6(MjWRX(F#RA;Z6Q=-gr8}Hb#D)lE^abEYEb{s{S+i>Rr?GA%sNvqyF|F}q|yp)N) z^^%4xCQ3Oual9wpwrqK)3=Hg-M5V*7Ob`GwQoAc4D(AZRJMg~;RP!C(%}k18pn4~l z<47q;3ztbhcjGF~4r9^c+>{OL^ru8Od}am9@hY9_*{ms?n4t^BHv{V;q(;NYAg;eH zNtX%>bWtJ~)^uO;Yu=8^qv@jT3T|2uBUQ3EYXEH^^@pfGlr~rX{@BePI@=XgO6ws6 z@S`@pKD7NHPix2@@CBik*Ec){!8a+~o)e;UQ!8k;G}wj%Gc zY~@e;{1}|`1vT9J_rAn9(MUS>KW(W`k>Q@tlKU-W1XJt1NrEAZddeu2r2V*r zg>1ksr0quYmFK_>alGRc*!09atxJ5H@w*=6{m5|HO!jfGb2Y1xtJA7`P!L86-;f4a zq6Hp@wsv#}+u0XSIf?d>ec9=IUR1o1Mwr3QpM^aWxr1P82o7=!GC?dusqhHX-7ohc z$+6B6p!wvmoAccj%G_Aho~%5Q-ZOkr9@d4{uAbe@qWGFS@io2GRA+em3X#ScKEH@i zk*$Gl@7Y6TLJ9d^<*(>|_}P5GEQ@|Bd0bwyfax0hg#hIFD}xiC_)`voSaa}bG9Nv@ zkh_x9*^FcYLOJa5DQas3_GCa=%}?>H@T3sEHQMR~BD8Rj6IEiTNIhB7BXYTA9D@^?(D8P zDubQmvoF*l52(rKpOkG~$b7O+y6W%bmga3>T@05d4;lK9esH0{3toqDXVQbW=0qOL zu_^9!=egxYbF{JvEmvG<{VV|??gmYAfQ-f9VWe@JJLeywhZbcT6r)RIg)T=>JOm;hJr_MPoPnCE4qEB~7{~__% zE{E>JaN8quemNuhxkv)C?{SqfMIpaJlSL>D7h~KvWXC^T%38xM9E(#{K-A$}RK~%T z*EVv9jJmS<_`buE>$1N(mS>hyG%6X(e%zv&sy=D+m04{_^zim(a?-yxYZMxIMTVz6 ze#HXBQn~nFS<*d+JHJ}rqogRKZtQdF&-M%H2)j9)i9aj6{85p>n^kZdV>1+gOB;~s zN#saA|H9sEa3tE=OmM0Oq&@LRp3F8U+J2Zbv~RN5!#X_){|+8_Xm55gjE(ug{iP>A zJQtJ+ki;30Xih&RATU*$FJ8UYx?3H(LA<06AcM~mB4n_-y>GbKU}vybI+Nt=tLf*i zh`y$js@2=PTsHr1(7D}z&~$hA?PL7B2|+90pNxK!0U3*#jY#olJS8s1g0Tb(Jo=v= z$lqW~U4M0zSYMi8WbncJ^gUjwQxwxKur@~I?#GAn;yP9xUM#YDO3odu4u5O^9GM%3!=0`~A}MZVW+IuUGoH?IDmgAD z9%ZiY^{ZpZnx-^ZjfFe8%`$1H^`x?YupGNS6$`z3eY$5fXDI(*L+qB{ws7e< z>`eJ;fE{+eeW8r)P(qHX({qZ%*QXzD&b1eH-Z(@xBn$hL(0`fPRg~X-T0<`43d&D5 zu?n?U7AF2b+U4_E4>dAWzwMf9E7i)4dl@iQ9S<({nD|p2 z4L0fD4d#n@O5|hSEb9F@l^~aIHpL{qJmM9fS>)K5Rz=y+qs8LBxra54VI6zWe!hh3 z^oF}*Tvnh10WVk**Q&+Y0MxVGJRI^SUzs$G=D;S6qnf^JB| z`i!*-hgxZ#a|CK?Bo)oKit_ip#Lv3~(7HaNoDw=kt>^05cIXCahIS{B3wwUZATgoc z$o5f-w&*zh1(YKHY@*$@cSMk~O9=DL>!D_(AT{BjU=mPhW>87NGw`cY5l!pm2eWYg z5PoY@(lp-3>6`v=9^R%&DSs_z_GBUkguXsTLLSU6Z(gsiXLq_tuKT`IEW7_~qD%A5 zjJg2{4h8PjGf1vBjpOWQ+n6liTY1aGrNydf0vgo^YO7xbSj6yobPP&Lt_t{MnR4%a zI7lgt66n^VR;n-&K;<>aA{qMG;an3PO`91CIpqWbg{`ZD<$ujsWMDhEc}u@ zp2uk;jB0r8^v(p%=nWev(2MR;E2fsjsd?qiEw1QbSbg>{GK_K958R` zH+k*f|CX3OUdnsLu_lyvRi?E$I0AdEMNy|RYR_=+J=2C98{yJb8sD&oH~(Ud+0Iln zK5dU_uH6Jxv4S+kXdC0OO7oj5@}TbD0r*+JU3gbp7Q`w|t$k*PBxCAohJ3`Gp!Q54 zbct(MmTT~5=BpBO8t+KbLZ8eix138vTSA<&0?omz^)S;=VIQ2L)y__7{#vziff~e* z9i=Js?{E`rH=?G*0sT*JDoYkq)D`y)?c|*F_U=(p-w_sKhMr>8F=dJ*DwUC~n}#3; zBjXVKMr~owkB)g{4NN=3&7KY78|PqLt7mgRlJeU4T%Kz&VyM5_7oyw8%v&4AKHrKYHiC-7V!&4nFsW4iY z?n~>b2PCrU<-{ryikiO_BV@5Zv*agLX-c<&=W_BpGJG2k6*>{^zhRxvr?l?Kg( zS>%5UYMiL(yQFQ#(o=JPf7n}xz3|1^n0Jf3HS$ZR_} zFN9%fhm7h@zC_|l7yh&_B=}=ld!-)NNpPji<{tABEz>Q33q-+lXepv5IyB&h7@Km! zT{pjwCoimAn*iznm|U9}f?8L;$=pupP0!=S3yKVIRw;%^UKSCJUV&U`sfBSceO)^- z=8VaD2z_@sd4%yPSXg#Y*uYI09O;eEu}6BgeYLJ^T1f1C+Uj0}Si(0;u4ip%Mdnd% zY6AQ2f?1??V$|vhHqw{x-eDfOPRh8A%n2xXmyBLAZ=Yk>;?-*XTPeQj1B@mzCxe|5 zD);xo&LtVjtgYHZY%|GE4uIA$eQ*##oau1G&v%wRtV@hKCG7{O)O>~7{ZVq%)wbFeR3)O|iJ-p(h;u_Ps0i0{X2ONUg#>6)o=(h$hjDz0#ONHg}~&EyL#A6SYO6k zhi#wnj8educQqL&pfuXuabr|CVP-mdzYiQ*eXdE$`$+jF^Z$2PBL-7?xpJdA|IcpeF#@#wVP;+)e z>XFbPK*?}9=-oUJKWaG!!tau42$ObCe=M)rIQXBaW&3p(iADyVa0g)1;%SsTjc0%t zTscHImRy?~9!qo`$cxM|lo(hmu;uGdN}@*lYVG`kwQKr=7Xb#%u`u-v(yq2WXT+=Z ze}{WIxNv(4$_kz;$q64$ijNA1i|vD9Q-3*)UfSHuh2I3z=Q=Vd(cuVWE}TwRwIOSZ zaL3K--`Iq3i*e%$ohVE0(IO?NOM6)VU{k=3jx(ndna=vvhmCww@qKJ3~ zYf-mb=Y}sFWEs-)+cj7Cd^AQK+xW^pI({a``NM({?+X#G#FO20{JOW=FVm;&w*{J*5?i;0ppvYkNoV zVG$cQHn_!Ob4E|Oun!xBvD=|!3&F6oDj!Wf+;9~s&s6h(16j8 zrS+y zyc(K%x18ftPqc($b@aRk-Zv2#bS{ptlo<3Gu-CbY8p7EH zmBKja@XH$W>4a@6W(E1p!2f{_zt&x_2|NR28;=qMUjHqNQ3dJ}#%=U17RH(a^E#(F z3XT4>uReaek~O?J5oDAYWL6P!gI1xW&BGKJZ?-uPlj(Viq(3|IV{IJ72m1X%D|o%0 zSR=0_?3IuZeK8~PXIeu!v(LoDKP$E;&%s04By&wO5fn7?K+e_mOWrl<>4e!p{|hvH zc%SXdjnAxl?KYN)=47r}b1!PuE8H_j{PYAB?bcXwO!p+K4%AaQYX|2h)mgR@F9~E6 z`_IaI{Qr9Q2of6yrmS1zrL420M4C>wtIK<`+bN!{@g(KEFU!*E6}bY?b)~E(-0A@j zOA`y?z!!B3oZ^;6SDhx)|L8qOP-D}oQDZq!`?hLA2+BndkMg}i@reLZ-tlLQwSt`! z->hQV5vQYsM@7n!*Y{}C|4xj z>h1vXE-8QKRhvLZ2Y@Ndo88KaP_7F2NKBnqzN`7D;G7L^D&;R`e&^{mIza2Z`NT%I zN*&ik_kung*9j%|q*N8_m5J7RO3)uN42|wQr7WsuoK!U9tR*SmUmo0RR=-1q6~##h z@8btol8H}*O_9s4x06SMHws;q-h8(53iTd9WzBhNaG9h4|H49G(%kz3TotnbB%Q}Fs+UaWtG;y#Yz-6Ke{A3=`?|$gzcE)r z5Ef~`>l8Le5}RLslVRpf^3oybokixAPC$awHZK;RY!^#Kw~(C;sjAyRx`@ayK4ZX^ z2)QVriv*j{ow1r^#N>^sdvLIuV$VLg57yb(ZPt&i!1(Vv1xVfJ1n^D=rjuAezgnRi zHf2z+fdiSEfOFQBM8Xcskdtd5>5n0Enp=c2{6Z+?C#4-yb#Fv}i5-Ga;3q4Uno3yS zWbYQl-Ob6T|;;{DWGbk;np{8S}89ic? z5s_xnaxsC-oMmhvMl9}uqb{r3|6l$ac{Xh33!<`*ZDLS%C?kBfz@%(;S|fGAeBe2bv3U?%{e!MRYaISPD^wW? zO%TJ2q~1@fIAw_E;60z~d13q#AhTPD5`R( zJoC{mH&B;y?L}LUsjm&NrhN%Jcniv!Z!Cj9G#mne(K&Bz_o(!sveGYyz0M~|2^n+PnXCrZIdNWI zD+&kE*m`}SLCPpD!xNTO1leLQEGt8HK-fT~^h(1^Qo@tt@VTRl#{W__xM66_Acjy5 zxc49Rol?dVmaxs!i%txCTxxz=9?Vu$^m?V&H-jzkY2r)PG@zs&{%Gk>(hjX+Q289- z(#08WhJ5ntzT^6*Ld=(q(>*{n6|;p<-J5^D_=M69f#xgC5iINP3BX;8cvT8gRqx!Vt-{A@{S4l$i1pa~~rDIwoM zOdVDoX;tHtBH{SUShYU?d`5pXIFbg%M_~sZO9r3;RYV(TT%XOZHUAme*WC<|>I@BK zNa{D5p=o%@YFvAS5qmA(spP@?1znv2+jN3*V*>JvvUw(qB|HbS3x~DlmxMfEsk=XhnQn*!U=(%#HEO2*l4BRT{c&^r{k$3`_-PW<#;i)NPEIgjtpoQKK69cDG|9JT@|M;?@B0Wndp` z>K%4J4=fI{7XY~)0S52LIscT$BgQ8Xe1%^AE|KigJk$=SF9;6{=zTq5q&_yF(pP=GEAmG~=G+LrQbMe)t~dqxToORfl)=0wvsdBlvqI?w zM^DzfmQ$QZ=HX~3u%xIK;Q{A|#|>bwo5cMQ%$u_I9!c-q5TG2P)Bbzms^>oIY}@At zSK`uMCb$9|cR;aUF0LBcV*ngU&6|wgM_~1TA7Ntu09XW{q+(TGT8Bj_bz%GdMfL)} z;80kQtI$Tf=L_Ls%pk3Dw3Gnt-Of)phdY3BA=B3ckcZ%<%W3-zv;(@?-X;g*>nfW^ z4zd??&_@5n4;V&LimoVg_Tat${c~&nRojH&_|{2QohIfGnAa_n`fN58x5? zC9Tww;Q4CPID%qTBWRv70WfaTCEP3#j|XHAQoC^oC``xm1!QI2TF;#J--6irF+QIZ zAtp9>CHCTAqv)q>|3$b4rljF1feuv*M!q?AKeca#Y2UxG56ew%qsGV6=8t0#g3Pq# zQZ4H(Xwvam9|O#Qs!09$S}JZw@V)Iar1o2`tKM#pU__5>*T-%P7f{C#;Spz>tnkc! zWx%*7s3{AQ292ryHVtg*YNUa;k_3TjxUB1(^t;OrKnJk>#kzN-8e?P9>CSM#sJ78+ z6+hxVL&SCu>0#Fag+=atq7!Q0^d>5hb4P(n;ro&lasLLU#pT1 zYim#-==#J1CK_)N(D&wBg`hQ=+FT}P3xs>A- z_f2#GyPBQd*?cfg8$i0Mm2rGo%nJw69l-8x0L08mn<6pUP_Zy`dGsDPEf$z9I$V(rbol@GA&~_8&9uI7<|UFBpt3@wJ6C8cLxLrH`SQVE z#2Vp&RLvgLo~&%=!^g}S=(p2rpdpw1nSYVx0UA*(r;GdRSqPEHeZ(Q+vIvKO63c-7 zv78FSZS(&;IhOha0DxuJgJQ%B^pfPnX6(wmP`_G!AfK5oC_B*W^D=I4LJJ&SD9i9IYpy*^-5g$4=@+g3n z2#3@gqH!}EG-Y5|4`{+zHVoa@pUzfUj{d}$Fofm}=!X9KpqB|bHrIb!lqEqg(V4#} z4j#Ebky$*Uu+ZUg4?^wCxXWE)HJ?H=QxgC)i>sPQC8j+A#q~xY%rgLZx@N@tiH(yO z@OaOQ$VV!B!8VQ30&7*}X)#sChHM9z6%P9)b*?}eMCJR94I;K&49t3Eyp9R-C1|nG z1&K+>YnL?sXG1-Q3>=)?0(~<4w^FX;og4$toc%I`k!o-Y(L_l@U+b44=f_t+E<}7Id*urx%@{4 zEERA$z%G9NiH8lYL>9(3n!Bi5QCbHbj=7V0VGP_bg#ATq24%HfyIt}cMxby{HnvFZ5$dyEgfY=#9yEr!SYBZ>v==7BR!d$60k zaUc3t8Um%JrJcO6wUg%kRPdkDEY-+xVAjAs^yvKtl}8ZF?w-M`e}T=Rq5I_5J17n17k(b0?? z0WPD8=6qCHRh0$~F0T1N485L)y82YMF&4!;0}{9(AL_SA^}asXI(%Z4ifF!SvM~D7 z|NeOvl$1;tTVhQ;;ArOw4n|8{jQkLsn48P5sjGWUz^a#R7SH?g<;$t>Ap`HAQTiBR zbHMGtu0J%E41`HN1R7mYz%uQfUZ_RTCZvY#J`IRlSXp8FvordAXh=!7@k7BvF+wcM zQTRCkDLFd7-aFXp<9PrJu?MTI! zM0%LwJ}~R?rEeN&KvTt&hFCSMkmCz1qh|Mv`5~$jvK-t2YG!6d0(QfLD>d~5lyC}R zJ$sjBgo9PoP_MCx2^t^Oc3 zFFQjdSXCmpR4VXQxYY-GvQ*_xn=ykC8*TLBwor%k}CWHxNQZ!@!CwDp~1mP zQ%A-a?b&9NCX@!VdnT)Gfq#s~uqyhkEc}=;YMW=j21(lDUFz%?dC|$Y2X}Og9)}x1 z11IxV(rHHW&GyaZ@gP`C#IJ7DjxH`P3U(LjY(;|w zF969EKD-^_25sT0MFQr1h2)&gDs94_pPS?=-AX-iCg8zOZuJBwC}oA7EIAS0wZ4@S1QQI5{bz79F4tcK^!R^ zn^sX%LnF5N)16+c*Y!sIw~XQ8VF|tE@9aiD!J3)hTpWH4r&>FzeADXHAfvUW)0UYj z;jr3zcvK?N^7Y{D&d3jq7m9mJetvMeEuJ+=Bv*gMXA&PDgdyFIWEZ8%LLxQc8iTwG zRzdGtv}1c9O6vY9Bm_yACRM;Wcfor?C7E|P8sp{XB$We7dCP^_*}QWwkF(uL#_9I= zl&M>&1T0tfgiFESMRc;pvwJksvfS;MD##4>M2L=X`lJJV<6Zce=DNPGXOSLyp zn1add!Io|}+o*#>7^5WO+b0e$7)=xX4*uJ3<{Sb9B%1@R8BQXNj+AfJpPzRZ-JPua zCn-C!GnsXDb=64F-PtR2xZ&OGPfV!S^suq8XdYgH@b7^xNcG|3{_e)t4KV?iCj9s( z03B;74&Ru9YlGx@ICs!=SfINX+5^zsen2Jv ze6PcYWNvQ07bV&5NjOsN_0KHygOYM@%)=_V}@wE`R8zvub{9{eTQcS zz1Up+;fwGF>^bi9yAy&AZdRX=ZzQy9$e8lI7b9>~fPw`_%^7uXo)oUMo#* z$6Ek>`2KK#hx!M7>Tve)-@m~atgI9h;5Js%Zw5M33Z9;g-?LCt7st89%|e&2H~TQj z`Ll!hQ%m78+6{5gsboj@XUhmxHL@r8zDknmPO7@P){?#a(4Uti!ut&C0GMQ?WNaMX zsI#d_p7of;Ro3KUF3XW;i5shet_N!91$px&bKfd*soSx*|M@0JcrpQME@7u$14@DM z<>6x903saI8Qj93%uE{k=!DN$BL@#C7#^P|`5(L}FUxc1f78*?{l0mkZ^ipI6%UEk zHt72kEiu4J*O9>m=5B&Ya2%d zOUtbAE%gCnj^Echkl8wfqt@W-#c`K#mf!-y;L(pbK&#!G*$K;|qts>H#QFKURLjwW z`NZAbPfd9@hkJ#4(KzfX>hAFQxf-g%)8rq<#2x(6c^#nwTCz=Hf!@d^2*x)`fykO$ zFR^7klpFiu*oo`_Vl-=kWkdNj0Hek*Zw1)KW%td99nj*L{1VW@n;>o?B+qcf*LEGD z59$bueo%+gJ@*m7W!-@wl8_5j13w8wbk?9Qs$&e0Xs#QeY&8HPadQrLllLEx%Q}Rj z7Z8ney8*?^My=Nd(-A^v{j8RoKyvK^)XnlE&}Uc%Ij${;dtL_u&?Zp*XM5Xy3FuzS z5!@4UtfED@j|q_@kiT;drtr*%x*#t>Drwsw^ni`b3~z1&G6NSrtG0K}(^{_^fH>O# zD*y^OKytDWRjEXg^!^Tv{ylK-yniQer+xn92HMuvLtTj1Z(K64u;3)9nBizc+bseC zj$Pb~syK=niQ$P}(=PyRM}^-2Y;a6ETaemtxTHn-u>zn0qzK04%YfrZ?#~$j)|1ih zV=Lw7Tc^5N!;cM+?!%AI_t!_D39-lIsLvZna2o>$wZZr5JO&E$e7hkZ6Z6mk5c;i9 zos>;sGT`!n;s~{pTD+bOQyqX2=dv>&JV5<_1%YPU5_luW+Q4xX`w8f|3sFY>4>m<5 z+o?bTkPnbyW?8yGhJf*?@`DFBV(mhq`K8Aqg|xfkEc0gH z0jOSsmbPw>Jy5KH6k}WfS;~`Cz2rwX@a0LXMXbtPmz??*2;)!CV*Mc^6*gGKJdU0BS>O5LV!Arm$J2xX8F+t)%JvSl8l*aj?MT)eA}_5RI1)#4NQ;zxD>2 zfv}bX$bx@>8itJ(MhYI)PqVX}ltCTM1%VQ;zqaeB@FkkPPn;jX$Fq1@;%F$}16>F~ zIl*jvbho_0Prx4yLhb@WT?GB_YlL?BsHxxs#00UheC-jxkSlcc?^6epO`N~p7ef&Qj4vu~7y*UWkD|=T)lk7c?y=V5`k&(Se zWsgY6-XjzaQiw9M6~gy@b$@=J@AvoDee1zF*Y%#S@f>U@wZ%|Nb~_`#_4Jk2;ETm? zTQBtPprN=K>USvUWsW6)3PPex@q;-UKal^rEP=dYIp{F8z>^a+o*=K!-+(@T%omHz z-Bb;!04)KoH={0L=9eVl5$<2HT=}kGhA(dLvDO0cb?G9|#SGi*3Q1-B`x7XV)LzWg z2CWC3jhe>wc_VB&`C;db7^LX1=8*QG%I>NA9O43p_Ih*d^`JE7HyKj^(s#x97^36* z!RL?L-c}^}r98!Y&Bu{(Xm*i+%sB1FE%pb)rT5U-WNk*6h0Nj31~(Xu3NRYG6b<~P z7f@~ibSFPCf&!`WOnZ4FVXUfy7g)SFB2mX@!ha!ObtzO6OA5O52dB(6D6D&;#k7To zIauk}h_rZrV1pB*AqOBHk48)k^V`6Y(FUe<2|6E)Ked;O5Xbg2`~=A&;n|%6f%9ug zkK*dVZUPVHF*Z$CL|-XPN#i>7F;Y9m50AsUmq>z;7YCKdsiXjhhkAo#a8X-yF^y`1 z4tQUe^?I_noB`S^ohF{%`&_dyyu%4?!-D5T$k7^w9k zPU*WA1TT^htO%q2w8FUq8~sI#S*q835gJ&wy0jxKnZ$wzMaI;i*HDuwmCUS^MQYlb zpmb4wZL^+m7uI8sZ*Zus9>mR_e=`+lH;K? zRmJpHg)rCv)A3Bz@P$LvBhYwQi4HG+Ll`cbDy#_?Oynuv0Xk3SXNVd|_}&BtxJG@)ayWIZ@vC9Eod%5I%az|nuRm8}l@HNUr2v9ii1{U_!ZkDF_D zybSf4qpSxKahhLY;&o@#zr=lpv_`-D3Y@0AEWiBF$5l}c`BTLDYi8eS`RVapT0a#L z!lGWy^0{^JIs`?dbp?ZG!c>}gKn;{EW(pMMX}m+?KfXM>xW`*fQEY{9@r&ar=eBz2 z=G@mAvT+;IfO^H%w1Kt*?t__TxQIqHvN(66m;z5~Jm#*)gQdO@ng|yx`x>rVIsFg_O#W8Nyz5*5ujlzDa59 z9bP-{3zKH&euiSk)CTPnW=&MkLA)sp)38sc+-57`F3E!Qg2mgseQxHxV13ORy6&jm zhqBn*LEXn2Z5p6+u)1{ung`4oqyAp6u`PzmZ&cf-;63f* zV_KBU73~VY3x{n3g<1E;F=36m>MHSfVwiGFKT6f}-YP}V`{`EHnB#JfX~kIQ=%7CJ z4ax%cXjmYtVexbjq_tY-IokH=&1}R4>tctDE7WQ*KPgYw>t5QB2eub2g{_WdT2@XP zyYESMSAqH~WlztHaH8VFF5GI9PWW1KI(v@-)s)Z+JY1o-P?$9-unX>&^l|x2BhrJ& z4^)S;12mS$8gEZLrBuxb9t6UWgWlCMFPVranPE=1Ca@x^X#t1c*X}IOw*paB)zcu} z{2mc!LV=^blef{{fZynqF@i40j~xVyn4!J%t_#pzC8*av(=PPOm2ANG>TlQvmCqMq zu||8!$|Lt~l!{bNM%j75>)o!sM71vPkQ# zPxAq{do!L;y5Cn&?Wba}^$T|GqX(TO>2#v9MKy37F2ZeQOMO@HjuR8@bkF8Guh#2! zttyHD`h5}7FG(*25nV3AFoSs?{!?}0<_@tJ80pCS)uNM1N#N-0ZmydZ%lng;nIBPi zcMp$QK7RcYSQ=Jws#DwSYvLyZ#V8i^!+8<7WuGHccTJY!tx5$n+;ld{dZo&5$e%ge za!hkF*@lr&FDO#4nAQkY6dBgMco=R&z(ok-7f5o^$L1ntaNWXQwDmg%S}68*Cu|y5 zg6Rs4s()@1d`#1hUZ0fzyF$XB(&K!>{F(eTDpUOAO|lRCrWJ0ncNYe30wV4?Wg3X| zsug}bgSJkgF1H#Z4~*wWUOk7^q~ci zU?$k(sMKG3d2z6%P5?EKqGErS=Q!IxuV&a@uT6J<%Jks-DCnY)>LT?+dvignM;A#t z^3mB%WJ1!?RjD#1BIp(!=7;V(?rL4QpYI3QrunMB7-Oyty&JHI&n!>Qlso}Q#6a#a z+jrb<)rkx@*WolP{~hO7@1~m;`8PPe<_jKV*T4BGeObb_7t0k8>l1LVU1|XS{ChjI zB-x(I3tFts&qm!@O@B3m6bynCG-TW~{;~-6wA$34hHI|khLDa-5y*c5S5a&Wxg$>{ zFr0S9-UjJ`je^0~uaXCSVh7#@u?1B`k}uZ_zyWvrK*cq}utOXvwe_l`$3td{W+gx( zXg_tJkE9CE=_7dpDbptIkG}hFaGH8;77bd?Nd%8oWDa015$?y+BtgXnb~!*H&R})7 zj=VPH4(g@d3gao=;u=`x1ixL4`9Sbz^=Iim`lkGamOPnDCG)w%xmV{Z`8j1R=l!na zt1mfIgea1)-ckohlUNDEdw<`ku8Jkb7N|rwb+B9CSxlXEEszMt_d6wT>8m~F$WKFl z6<|5wby)G2XT_gOjq(||*PiIL$|V*1?XEykwXgKF>sJ?v{N##WVrjlFKgVD9T>t&S zNWiBmrkDy(`o&4Ls2Ue# zV7L?+kEiI&YUA_X*jc$LSw?B?pI4CyG!|pG9t_Zjv*T*3L5jp(^P&R!1?Io~$TlE4 zBf@ezXgi&K!VsNi5_mF`)u3r;6OK0~i}<7>d%jVkNWK~CFnQQiqLnIFlsu_>z{Cc= zHmyjix9J8EZT3YwjGX;29E&YmO4P7Cr9Tam-(WU|a%o4t%LNFb!RdMcXiPvr(lF@O z=;U(Jyc%DVzUXQMp|Y!4Uln|u1E~smcIyqO5yaG03k7l#-$$D?xJOR5?7aJ))Xkc9 zmEfK9;{_#cgk)pYr0=@Qr029i<3hN1hE5Uxl}o(U7S#pn+_ovHs?d!&a0MjD)LWr5 zM8A5k!)7?gWG3HC5B#YEX7MT$)NP76~>~{P zJIJLOVEdH}=WBU&SY?4LOsjHbg48LYr5b+$zn6D=N}3v5OGBVJKvXaYw`NTLcVA(c z$h-5hi9P{+;y>dg-gsV1#0PCpIrH0*;D!yZl_+cmy&updO!zTj%|C{Iy+ z_G8%ThxgL%Rj)v2v`(`3XTy9u-$xlT`(0SET+y)w3Qx{+0RV*~Uoxs{iuw=ZP|t6W zVhMgO;L1#Qe0CkZPtPlkJf=pyzT}WOP?5;3Ac^lMui2N**6kB5tTM=~TU{If)Z_Kl zk>&4BtkbI2iyoi7NahJY^(Sqd2OPIomP8aqVs&R+9y0_TMo1rUh#kP@ZsDox=KGpX z^KyDWgm#0ohWzN%{Ul{()eloIo;`lfH@uP0Kx&4=HcX3gI~J@8&Z8SJ{krbiB$JY~ zKeX)r*Rn^*28m;Zwd82FXEc=^ib(cv zKJqakpwBqjXy45xF{FZjFwa;Yny!CtlkBlQcIumPSATZmX7d+&U-#qY)Yz8SMCG(5 z4o|x24rkven6T1qdiO?fOep7n`;_|VuqOil5H>YDJ@Bq#@iLl3DBaBBk$92jvB+lv z8^f466C_`+2*v@^uh_Y|C~-&Y-{P4o{+dIYnJSm-0>OCK{ueW@U!^U5YuL__k9odJYF9S$n_~&G6Y?m? zBpjR*ebLFco>VY3=5sjwZb&74IHXj9G#M{6So(-616yc%@a$%M9YZ90X6&A3k8sB2 zM`0=rF1UscAD;Q6WF0u^3DCogu=W>W+#i=O)tdCrC_SXe=yl~_xzE>;#e*^Gk!dFb zoMf^ZqitoA#E_fZ7&7tJ&aj`c?@V^`)t>W_xXTlZcOWGx&F`(gjmT<9QnuU~)>OgC z_^pYul}u&fb85JFUSJe)-T1egm9T^MA!2Ig6yyuzvCiD%=~W+5!4QS}*!**PESF`%)QZ2lI z4JqmL(G+Xb5&9frJ+)cTHe6PCQp-OhJvV+89`I+_!$M<0H_AkEbxoyn_3nf5oz)$c z>!Ev!e3?ZLf-?g%>#TOUw7Tn?46@5PwQ74Ne`lA!)DdTPV=3KLKGmNNXVPjk9Cs56 z6#2;htx04g>UAd9=k(Elw1oWtU92~E8_yh?zRcw7>`9D-&Qnj6&4~6VbkF4OZxIkO2M!bqS2Ri?TdO#d9cv%{vmw-&nmVtBUr<{d!{`N9C_rL1^U}VnjKDSRa|3tEjuzqbAL}5VZAu2 zPw(9`Iid^tO}Df!U)q$dkOUwpM;H}jS2K==xVgvIfB46-<~8n%3O2HSv|;NGJbwJw zhUqUF=P^>m7$qK@m?53_)BpOht;zR-Nv&#p~@~jb;x1zb}1b?Yr{1MabKtt|(0Xg!XWY0hZ4cqup z$EM0VDzXVNHHw^Z(~@6-RoO_nIEP_gZ5MBSASIrl(?HFAEFEWNcR4C zKK@q!{Ai8C!}o1D(`;xwPcW~v8z~jsPzjbJ?YO7;_ff$SR_i5w4_1AOIu%c^w$aWX zH9hvuz8vhZI(ZrK^gCP^Bs=Q!*myaUCS;Duc=SW%Fvr|(HKf}+nFXmL^dB?%JgTs@ zPkRk`n5Ux=*%lu75y?8}l(4qB2Tz^Fq#rcHDhkYV9`Dq5c#F0_-DyUJ$x5F59JcH4 z#Gv8bEbY9G{BpKa9w|Lazi8lmUtVWSAGl@@k zf9x96CT8v3$+kJ1(`@Cx^jCLrrcjc%Xwj5COhVivC?N02C%u~DHR_rDhECP+#|3pE z%+HiY-{xutnsVHvB5?*Ou!$Jjw1HbLu%vnLI}%2zD;2;0@p z)pmy-ZB)>VfMyMUO;uha`x*Uh&sXd<;%8>lil%*m+!_{s0I;SUU{GsKoYc|B_am!b zG`rJglu^E9)`yu9dCZtlyCaGdBsnNdtGF>ZZ~Ysu9XI`zj&| zldx$iA>Q)d!)EOjRqH~v#+Ry@!@{X*7uck~REr25Z9;z5etXFGn6zC)ZxY9ygAL2@ zb}?fpY}q&w5sOh>WV$taO!(`hXhum+g!4g24$1-bh30P~K6+Iwp>Ss9(#+NRzQ;b- z2uxDQZRjT9Utb4MV*je2I&>+wC$kpyhno=frQu%11qmE$L24nKq%2)5r}YbNsLC^Y zgU4({b#&oBD2RASVVb@Ayy2*8mWRoANA531J}BB;yZ#eYY%)2(`c4JUtQR)%c<=F=+V=<6fV;uFS!Crls#D*4CJpYtIQ*+X>v7Ax(OCI(mT@-D=!1dZ_MH1%k8ZZ48PH7m{ft;_S#Z@;d>n??nB;)S+8`L+Qm^wlDx*WksEncolo`EN1UDQ$cTe>-r?}B=K2Sy|$9;G=#r?1UV zH?jG*F06M8o;^{FH@X|-^9nq}u26i&$M&OeJMujuuh=8XN|H?DI#E-b%{lj%t=er5 z#Yxf5g+2&`8_% z-di$iO()6!V}CPs&SOpdLk2THITgCWHv6ypC^6P%$#}&Zv6VyDePw+g5IK5V@EpCN ztZ$YbCiV1k(Yl#8HW9jV5g0w~k=Em4vw9^w^hQkslI3TN)$WGI3J#hK8at+VV zQyXdedaG;UM;c5V84si)xGmM24OYtQqOD`|1IoF`5!e=!WX2d=((Nx<1(Wqf$M`rK zAL8_7vYY%xb1JP)sx6Qvik@hXXxT5L>OT zG5D|AQ001xv3*_Q0J1e7@gZ&lJ6S)5`+AUhh&N0^QoJhweiys7e?t5UQS&BGMMQvX z@&4M(f|?13?%IsKf;#xv+W;i#$Tj7pyr5>`wq;H~pgs6kCG}tX9sr(&smHbGSw?eV z98}L9dk1fM`tq+{k==rzn3a`&TGTl7pQ{YZ4Gd7mcvHPxB%ys|<6#?A)W)6<(RT_{ z&wK&pSNRESBr&6HEPGBgDH#oFZwvfD$@o`cIPsS6yxZrV4Epg}(b~<0$!pMgYXT<3 zXz$|rq9&S<`FXsyL2_-$B7Shr++5#adC7gQjv^K4PaTTcieyp=oX8$eWF%Ahkp91d_g;z0Y3cd~A z_Gvl88YXiV5?XLx$1J>VDwo&uH8SQtqoprj(K-f>oaT~&5sFaY0!w$7taWX^pvb1h9d|s%%9m*+Wp?T%&c72 zR6WLLQv!ZNeEigr>9B<(y9y8+?vitsaIdr(+{z_`y7`k(%<$e{ZHz%94CsHOWTcxA zKIFEEHs5)R?Kp$D!bwKsBkZ=GR&RrKacw$@G=CH#%QK-K5uXh@IrhdTCq7My5qX%~ zKn$8sl9cpA?2YS7?wuhqtD$5J)oTDyR!Y#jgD~#{PyluHzAzgt&X~-QyO?weW7v0a zpRF_voSe*=k{IlHBi1pnH$#K<`irzgQo_2XgAvWO^Go%v5$w0|E)kmuyO7v?6*$z? z7#~ugd<3kH^8| zA$|da6|*}wUgQ(n2?kb61PATy zUPg>M(xj`qpAS6ecwyA-TtJsuF@7G!groI}gSs8C4Y1Bh?%XtAWT`>D-jpxTw1~sF z90WWJYGA>`@<0f>BY>{9*|WN7x^ZEwz{Yay#PaHG95$9J+*Q(R+~Mdi5=yteU);XT zvz@Gv9K3m=H;1eEv3|0hi%WXj&#o#>P=!W6uxaW3oK;3;X!B8N3 zsvF4D^54&34}mXy2EOp(10MVa>m)5KX0>a!ag+N-o}a<#lX_F6l|h9>?NGk*5)bsd z2uwTh%b#L4f>~K$25L8QmJ`Jnh8X=fip(7rGRSw#FoAfiF1%G$W=(s~bFSNh+YbC< zgn}t6z!f~5sqO(!2Y!Y=NU88Qw0kdaKf#MoFh{VIqA8ymkv4zl*DTK-g4aKM3K(1x z-7s=dx0koTO#q=2oY}#rIQ5;#us*PWq1U$n9m)rw1l$dqBX-4~056F9XxA_;ivM}p z2S(sg;|(Q7vY>DK_x{kMP=bH03@I*v#*8==0EHF<&o?Wa6UAmxzXrlgAo2d&%?>7j zAB~@_Y_0Kc*uFu-=5twjtW?Mkhng9oT^W)4ibIfIMGFw3YQ|>fWc%;6b^*%1xx@FS z*#EvK^yI|QlbeU6wAA^G+e;FR<;Ej)nf8DKoTFUv3(Nd&l@Gb@Gr|1(4QU=dPs zAFD#o0|xuQd8USdFOde1m?9N05R!n~y9BZI%%d4~L%yuNj0CVavA*`D;Oony(TIFL z=&v?Bz^h!eW4!vKRQM1E-?@g|LIJa(e#-EkA0tOl+Cw!r08g>z8Ld#lgTBch1A5$7 zoa+zaYcXA*f%#L7(d#pV$Ad5G1Uaj-iS9@+^Q};z<8%4OQ>oAz2E4wpA$!4A&X-0F z>rEqRT@Zo=!oo56t&#S}@IUT_e@QQp6BRC;*R*Yk`YqA@*x@8RdyKoCQ2B@ofuT_dy9GZv8mkkUk`PwFwnp3 ze0||r|KB1J1;6a*;k#+i`oJDGA%7k48$i_FdrU}Gd|uyqvO+AguK)qq+8~wk$IlRk1{QKKd|76nw3G^s z;4+}b<_tC)$y%im=V*l}L^OH^96^^DJjPP);~YPM){Me1G);ckmA*=aq_A0l2W$a& zL&&VBCS}xXoCOZ~LO{4YS8&GuWO=&P^xvCUDJsCk3GCmi!_x>cloTOgA}6oThknxX zv(?%P0MOG3gE9(*glG#gSR} zeH03^RrE0dvn8(6M|VhdcAb(u98-?f==W|99gBLSI{; z+a9+R3^#Hg=7fCt{y83AQ+`n341x|GK*ri>24-rQ>-Y3YF`?B?1*WS*$8@9`F_ZwA z-!drKjDwb7)~CtDYW5JKLkI^7$&k#FKgNgFLZ}>c-reUl>8FDJ{=aWmsD)r;m0}eE z1fe*HKY%>D1aV@MBv)gWF}`*2zc1DJM{sfmXRUWMgL_O4K$g8fej=*|eZT`^Xr3NE zzZr>m71srTZM*+WD@Jd%p}1bXfv;bD2jSKaXr~T!Sa9w5e*yDCQ!+WlcN2CzPGSlNQ-y=Xnv@rIV~zatL} zB;b?P$?DmSG$39@b^%Y$HC=scO6dHYd#}<-O%n>tOFlvdufM?P2)N!#|94*~T!D+` zhbitzC;~-~31Q?Qh%iOQYo{1*i6Y2R>tQrxoaMt@Rq@}s_kUmdiSjZ496iXavhO z0JGC}s44qjJg5QPdykxWYLyCcFacq<9rBd4&z}1v9 zo;NT=y`bhk?jzk7s)`{?pCMpzRt!a%QW^;u9sE!*9jtE@vk;i$?5>dS8|26V;TFfg zRZ08fghB>PP!{4`S>3#qCBs|tZ;fd~uV5FE{sFPUf(h14ehwhx-~I#R%#*-`AgVb4 z-&+!VG%2}kC;p~(5BYptfd@=2nQeY%=O7lr9uaIn)%%`uvt7IR4}-yE=&iTTi(j` zsfvZ>Cf(k8g(X*#ARPCdj!kGk5MVYufCyy}OB2JHCcqUrXWNy#0i1Q~KYe*sXK@6PX5qVk6ug>U{_MigMhol$&a2q}={`Wq?Zk z?Ghk-Ne)n8gu4|C@CHbvasdetQ)+FH(8?Pif<3_U$X52qsX%>? zlbM=p1R*{*6ndz>##>j06NV{#HH=rvy*i)kyzM9ipoY%TBJ&Val%)y+fK2LU@FV$& zEg^cJatXw=jK=E&sxGuKd{!9^94k6KrUO{|%rh;24ffAj~p7LiBj9 zhyWrn8alEcCBpCnU}uU`(%m4Z3h@0a6@K;lS;y zH$UL;E@ZTbTk*DCaLVIfhT_KpidDE<{^bZ1A{4CCZ+XBk`&S%q4QfAWR-ktUug8DM zYc8_Pvnc_WkR5Y1h-pHb8Z_lV;O!|PB6}`c z%qTbosb+rxpS}h`q;Z7Xz>lY>*fghq!{Y{B*Un4awt*2hMyF8InoPjBYq=`2C5Q{# za8~~6;JM=Bdl0a;fyFZbc^%i#FZ60>@a{E11t|q9fz~y$K^y`wZmp0*eUE!E*P$AN zF&mHBg82*Q$ER&c|IPqmFg%7)22W`HnJ_Xo>#IejfNqV_tJi?+Fc)OMm@(H?;2@Pt!; zj22j-CMgZRt+vuN{dbUSLH8Jk*?S{+6Fml;6XR0f8*&a`$P3`?Fm&md$pc%dJ0~bz zM;C8l7NjpiExuLh2`9tUnuO4uv< zZpD9tI02peMi~C>2nCVoaEdju#0?<3vI1Rj%{O|9bs%D_G>F4`y(gG~!oM`<1B9Z}nF11c#1CSR`&^uo;3n%*WG3ZppXNWbrX-85uA23MT z6<(l|yb1zO@w8RJ8F{Z<_@@{7g583sFZuK~@M47PV{8Mh7w#t8x?8Kx`r+mo9Cu^P zTtVR|+UMnP|E;#2)ga)1NbKz}e)RbQiXzZ|!thAN$A9KOa~|K`h57j%^iyNag1hnw zIwheAC2&kFe|`3`7fHGENbT8+e*wuMXvm81(VrGc9(W(}e@cI7l1;>974fa4Rgv9MB{nGdr zxH}4@FONS#p(7dm7~D$qZ1_)}IgvP}474Of;GnFPoRB`Zn+|nXSXTF{_y{we^EqL~3fR`oQZD zK;6x5DH;HHM&2UGCH-$X^OJLmzG5Je`?EZWW8d-ez}YBSv&W}s9Gid~soH*o2q6C& zyl@QU&#A;q3XL0tmD}hwGt=DUwJkw5tfMDXcwP8mq?usROmCwd+*km<0Cf+vWEqv3 z0L22=X}C7%gtr9{U_@069AqQ9H-Oi0Jo{&o@D)&}+JOMlpWnCF261uAVrnc5P% zi{fme@QPky50_i01Uc1Uy~8Jv&lygIm0T`>2#nFoFutjj7sK5hXhgID#3nn!N)y{A z%R1xOI`XohY#V~3LIqj;{4+Bmx#}SZcss=K^?JGf`szX=!It-<)6Wh-{dzhiJ)x)0 z;1KHb^9(}`-$6d4E z(WhkXBmh4qIN8#O02^Gt&WCf2w3Iv*D_=E_jJEJ$sEq41=tpg4faF8L!WM)AaM6jP<(!aBFm9h;|IQ6}0@i9I>aGyK z9#FUlYb7X}p1e8=Dmihfn9TPxMfFw@4{JB)ZQSGsXWJW4Wilj6$GpTTS^zO+_-uOj zt5mvaz+}L!Ch<-~&u9ZLUTme}BmHjgbe1d0Dg`P!q2ZxyUQPT-;#&u`_f95h@1`6; zd_%dGhOrzvTg)ZlQ015}IEg8;6HHV!F#vWJmiXu^??B$pDHpTT5Ltfjw`h+4hCELR z4Ef`sW>({&N+L~km6$!r{74Dw9f;#|0nQzUoiFE+eM%-%Pe?x(V06+Sf|_=eZBeyF z7jr@%$bj<{AIDQs3pa!G)Q3ICDHum%g3nrc#Nyd!?$wEl=ZtaMI36F@jk)y%XJd*^?S=k6Wia`mO8q5+Afn6LD_4_{0QxIXumE- zrjg0drjc1_ce%6|MBRQgMWEhm2^E8RiQhD}>r8X)hMQ@1GywLxl9w+M#ol|%W z2LLnf;g#7E2WSp3f%#cBn*pDdNf+BcU$t~PeoDv)qOuKB5ET>X{YHzediI3KqZ`~S z8lU?}9*Eb+^5-bP;8KxCAw0dOG6Z(m1C9%%WD(l_LNI_E-eKYzQ zkTi?V6&Fq+r?JRIdOT0!1n1ua^_w2ti@7GmDv=2Jc_PG!toIU}F!jSf9R&I^9$o%* zt|-4Bk3T{g)^CuN^6TZ9S4OKL_AEb@uVtxba2@asgu|+`?6eLB1 zTJjQbuc65>VXcYSyBS{fe(MdZBaJ~X8ytQ0o%z}oME1e(Wzz^5q6}Q6a&z&nXvUF0 zXdh`ZWvU9v;@gPZye^8;nt!df3glG&)ihDmAYj~8Y9QKN7Sc+?$c=TKw8)Y zK=Z6Oo7>Li2Iel+N zr>G~~qetmvTL;XUrm8`%$;zNp?G*G3FW6CQe*eY=x;UjAqMPlgML$MmMd*-^J!b;h zPTdqqmu`umo$*v?o!#vo06wKvM zY$#c>4_X^M`1-mF%)BlTi`Y{0dxtGKaey}Z)?Z*1%JT&u#Tv?|P!9Ij13atHKWXGz zq7UKepY1ayF>^s>&)oYinkAzwc0I1SkdnkT*F^8u_Ih_dCwoTIRQ?#qGpm&tWvZZ~ z1hRO?-4&%<>ZdZ~TmBr^!4)oRvKaAjTgUuS)Gm8?U?tN=p8cof1jrsN4+P$<_$sv) z=viT|TBvE5UjqH1;P>o05p(FO5=P|M+MEQxuq?{%!_lzO z()=qyM>6^m9{)aW`PLXY)Vv81ycv$48CY10$5R1uzt}(|*tSER*Su`eybS%H_n<+@U z!+UK=N5^MpGP@wYvAKl8xeNIK_E=s_|G53D(mx~7%b(e%8)p&MSK-J6>6DUx$0@FG|7<%SuT8ph(f?NsgWcrydFV}kQpkdsv;_m|&S zJy5vz2X;P@GpxerpcNV&M0NBWU~p=GUdrI(1>p*FVlHAl$kBSC{})1;)_s4}_5Tb(YFytpq#_4Z#!Gi#aDlncMl@xecz# zhJbElFnvRa*6dNpaw)7xB6NuCHZE%bMYxf!qDG`{NZ~~XvPoH&5{{cOLsetwlznb7 z{1WeON7=5-f+!X*QsG3Vv>PpcYiL3HeWaMdZRBL%w_P1`FIZTd`;Fo#A}{}F86@Rw z2FesNRtBf&w$isT{CS{hro$s=1J?|DD5)o752s2pPqqd<*LQ;5#R6O}uyVo*-D;c1 zC04e4sZvdKzir4*2ITBZix*ZUA&hN6?nDCAS|vlNm?{Q<|6b(_KtRg$MQEo8H@=C+ z#9A~-yXB~(8Pr-pBtF@*O4ASX2HJ#g2X&K94(IN*@{+tdT908*g6kDWT7OY33O9RX z!;1y^`{q%2zPa;n<%q}B-@}a|($)>lEE-nL#M=Xcbm^t8`^&*ND9mBYqnA1xb%N?< zcpcf3D@yKXnzuJgXKrnxUHEo|l8;y|l*GDKu-YEsgzzcxY2>~@;IKBeQvFW(wgKOy z`3abdg6O`VlHU>-K>Ef?#TRZ`i}y|t(QJW*t`;Y1MvdsnRf(?@8Vh0cao9pNMSq=T z4w!z3=o9t^&{wZgjwc)*y1CSo{Q-^{A@!JdPi@w{d0WoWY~B;6`vW~0+%25X!URDu zI*Txv5aKEOz!HN@=4MOUubCV`oRi++g=OK1RMLDF3KR&Ww*$|hj>V9a*`?frjwRw} zCv^g|R5NKaMe(UQ3!(U7v)9!O@~gvqT~3&+KP1#gOCnvTL-DciAO7;~cBPsp)> zsXJ}|F$(RkHEFSY$Jcz|UH6jXC$lz?jz@+111e;tf>U3*J3`r+406S=UZ$RC9P>N!Br0>OB!8eEFFUc-Y}U(DOie6LO{AcaXDO`0 z*EcM5b|Wr#8Hjy(6>*_=)AkdA=-Vd#UnTOavJfI|Vz;ExKIlZQ7@rJdKm!zUdttwy?iJJFx{~ zTpv=0j$45^G1<3>o6PcEoFCY$|9+IpUwQdjxU68}<=zn4-fM;jN3dH52H_)(?Y{#i zP{`OhJ)OhvZ*lSduzY>Ub+FdJN!VUq^P>5=LzXYU<$ACy4!Cyakp~(L{Ia-XvUr;9 zFCBmR+6;$$E0;JZ6%fH$a{dI0PJmq|Q}hZHhwsby-I2+Hdu=-M$U@9;EI!LYQ zbBIz3l3~5uo>BRNYoWA2?}Ts`sy?rZ89VhNYa?WMG}@m&TOBrE6-$fd$|?#1T;&(O zt27O&rpxK%D{bG{Zs-1nHNLQxpC{>@dY-xe>)Ed7>dRk1gP_;1lP4^XSl#6wBIq}- z{Yvn;@i}uX;cKIxKbb-vMU9VGwUmwGsI9n^pG zz+k4&#gHNB=zl0cE$&37GG_#Ke)MRJ&PDmeH31EPb+&o_HR09)`KP{GnTUXAsqbu3 zCr_{y6zKG>XMgg;v~4!6K+o`F8l}w?bP9$%(^s=C|0qlzI$rOloEh8L^f^vY1KEjU z*Pj(uBuHrP%COn_?aZ9DYx%Q(XCcB`aNkU-nbKBDbo4{XTPt4H*1v@eP4#1|q>i5^ zy$g+pZ#G-SM77Mp8gUN`X7pKdK2YNrNbW*k#^2%3wJTaKS2?vn4Q zZidI2Af%_VFL_U20q#$39pL_a7b{Qyq&XC87uR~DH0uNP5tgR8pk+wR)YB;m9YZRv zO!+`I8+^wjBMrfn!|d;ImN)07YWrD#;>2H%^i}>3>E~7YZ?)jTwFIAde>=`TtCEkO zymq0MMWCLIHr_aAxXoD8unbS0Rg!XA}D*ki6jCEPWeIwSL;Em9x}iKZ?V5?+lXp(8axRiKN=dTF?1luoC#=}ABBJR(_Z&EJ

    %14#k);*+;3)q)Y?v$GOh5a}qUIGHzeJ!PdyjN@BJX#_x0#0*A=&cWV zHW{I$`N7k%&zmYVF%9rWbxj=24UX^g@=SFRv%=jE3MK^r&=QA_dS#4KfW2RZ>Jz5T zZ$EhU(>w5w607!SEFTGPWbVp3cP))Wh!Q&T#MT(1B@{h(@GfsOra2r|U;g;)-WxgX$yiS?a3O`=oXE4nQ zsbh_^qr%JTcMmksh)cayiK%ITPR4#TT-uRwc*BFH$Fe_LPifKIq$_$j&|v4%+FI~y zbzFO)tOPmUuW*XuzGYn*Gi|j)fNWLDDA0 z*&@*isWGh?@k}eToh&I1PtZx4KXZKYrQy(rTs3Jr^XXBciNZGlQ7 z3V}ssVHCv8QVJba4IB|#6g>?d?UYP(3_bOkb?rGbCP|#;j+$AKbL2QCa3hJb6x(T9 zW&D;!=Qd6K-kMYl8pXvI#BQnq>Y^2JtE~06RXy%hYj2`gGTIS zu8O703qL)wO7GpBx2OM%S|*G!`ksUD7BbKNv944#UV-X4IqLgVG0@IY>j)szgzP6m56{KleM#zk|G_uzDN&=% zUm`0(MKmJPvp~VQZhEDPu{!~M2c`Eg23hKJ$_!8I_leXvf)*Aj65Jwj7c~Fuq3}hW zo$d+UeuKokBceY`l-M_H`o4L&5r&h-=MY`DseDWz|q#{8g=7sCce}mi{T}oK(ro6Q<(4}ixYNc53`c!XC`!iZ|dV4HpEK9F>SISdiEaQX3 zKN+8Wax3QuR;1LtY=!h#dv-s&rkXobmyn55`shx~p84pZ4j4_l6lQB4zK2;ReK<4KjQLf&u#ov? z;Isb#+)v|vFz?M`7LxBzsa(xGJWj}r;aXQY+O}Tb1raHEHp=}D#;u_{(RV9(Fg82H zTGnO7Y&1#iOj-G;?it_Ar25ln zV97pX;7lA)yC%(kqWW;)=3%1IqZ0>~><@iB%xa5)1QG~K)HP8YWYK3Ss?j;H<5t#!53)DsRj^R8HB5AF_`VL^F3=`UIChrH+>LwB zOylH)r3zu-muVD_JPYmAX$|>-^J6G18@Rc|{x(|BK6{K-^Ln<0Ac%m|_)ie$(@OuM z1a1DNp?+tM)8-rHcTWY9 zy)^u3vz2R6feM1dLBUtgk$<+KQa+~7PR7W%aax5|jqWG#_6KJ&*Ufm=f3KRzTALVV zSYDp>sXC)8ibinnoz18+3006HdedImML99q42lCTY=8KC@BSG7j$qbgQih3C7;S9q z4OZ8yAKwOyU>zS4b3$ro-x~}|FVOZ7vem)xQ|LpA+O31~p>-v)I_^xJ6zx^?nrd~W6JZlD3O*H^Tcv6=J({Br+jH=}0{CJ6@fD2=!qSBkKLdQ+FGySM5 ztHHlaGK`T>@OARN?w@Q=hSzaa4%7x!qgdTJ9^$m6NAX{wi<#7X zhP0g*)h@BuO)SdfMbo3-Abzj1Mmcq5VWeIAur8e8WgLTn;XVr2>{2E-lkg;flP&NJ zu4%Q3GC}$2oP~cCSqF))sl?)FQ@yDRh%+B1q)=1F7zWbwAYBEolz0Qdf=Nvx3R@Vg zTN+#L1xAd?tnissCjnAlRb{*`|3}^LWzpGh4#hx&NIkv`nU8ia?MT-bu4TsD`KCMh z3?+I(*yA+8$Zu|Hh5p-3^x-1UHwNn67GTV)BrT zC>|k}R~K8b!FO2SVO?@XwEDCP=1&!L2BmZq))9*DwMR0jA{bugEQ}v=i0po*;X|li zN#FGeFA}Np9DG35ZRNJwvl z=CSxmXRF;3I=eTCe|A;PHcc1X92n0Du;T$0D{0HnovoN6Z!+%o`j+rFxdjilb2G5SWTxAZFVx z+L=v~10%_&u%@iJocSA7s!F%6;4cJcHX{ni3%#G33`WN9fnY4O+EFeNykl%)LhDYL zdk{=)D{pE$V*SgCk%T^G`UmB(yw$ge#Zj)!slARDkTJp0eh)n>N99hK2Z`4q|NKiM z^a~*{p-HGe*fsUK=La*+14ji|Ym{i)*-@RE$WuntRUCjk5GsJB5i9+Q(`lD-lOFvl z4ViT(c_o(@=ub3A?Ud7jN?a;Ne2H%LN7S(@Dv=T(Qc=McHgg>M2~A8 z@}z*xK`cuI&@lx+Nf-cgWdMF}Fu1UoXB*xV&5JfRj%iba9W#5k;U|}@)`ZFwmIc5_ zRek#iGD=j*CMQ-u{*pt)E`DyP)S%se5e&Lf3KhP0F{R#b_pPR-UHpwRu(yo+rL>h? zdO-5@5TBk2y4_DGdReE8)C0WY?u<(b*4VU?;&$GrdnFa29+{q%hlB^cz$r2b-E#$v zC?qZn$?i#7yKws(6?kTe)Fci1g)&4fo6trU>mc5WQJXX+NKi=8PGQXc45Iu&5#Fp7 z^edP4Y3l?ni(q@$ntklHIIf0nus6eN(qQ63R{jqup;3UIs{4s!NkaG!?3QZ&)q2nR zXtF>lIWxENGuUmXGLmHheQ++v%Y5UHu7J22s?%{nTXR^NE({y%23~YRzNPUilQG{+Z` zIup%wm3Ge%ry&nB?bt+=+W4($^_|KAbZrOq$xQ5WzmRt1!8sos0FTx7q$B=3dKPyg z+T)B=Mzp-qiEKV<8DQB;zeuPjR}(J$ZZ)I{Y~@TdqDtT@EmX%AO7iGAE5n;JWU$A8 zQ)V28oR<-bezZCmj3k}J7?BPHBIM~5GuQsCy2D4D&py~?J>QH*qw#zKz5kW&7%CX8 zoh*pc)~<_gSp#+^pDN>qC&GYGh=%jjjoum?ah_N8>jkaw%vvJ`c~=BMsjfthf$4{5 zvRKN7t!emXpr6_JO$Ie3M?^UxP+wQzi)D>ynY8egzML2X3S_)aOuTofZZ)B`{JKmZ zzHrC|KKl}Aq8_%$tI;YF8i7n9(|BWRUDfcZjZh*u3AvY;QY1hCX<9gdM^Qbc;8IJF zLRZ@=C48)#NA2qavBiM6K&ZF1D{%7p}^%nnq%m0ogI350b68-n;0rt=TS8f}l z0!Th^R(KEXdvT(2xEoU+Xw@87%YgEF1X@{L0r$E=Y%5W75%|XpL<+gO9_cOup)Sor*uiSt>m9F&3td$8+?^4A!qWDnnrib23{=GK!0DQ-K z(A4k)2*oSZ&Yc7fdGYdl79`$(b%9N^lF~d;qg@Qw-S^w}am&N9-~I0y2e2aXP~xne zMBiF8iR5e-0NlZ}Td>ktRP#S0Qb-rujFVY}^N8Dj|Mq`U2FK|FH~*j?a40+ECuzF? z6<4}RAJeToNfKnldfdS51^v>mnK*c2IDul zajp?iD&zpXUpU#o4Axw6%j@K`7DCGYyBWBO8i18|W!2`->O-FYv9iC_yS^&wj4lKlK8mfVMmu^6*V+&u&bPR0ZzV)!vJ5D6sde z8dHA?xAk{=lp`M%M&-eu68RJ53!jPtCo|gSJ}|o+P3FE#FVF4`{s%6N3Kdy$od{X6 zo|!TO4sIoo(8%bQ&{Pr7ssfWY3$Vl()eh|ctpjE&>>XRU8GX8`*@}ho@Ey={4=@d< zxBv9XoE?5&At`$i(mMhUWwvL2AHpEOu+K?vmc=VKf)Gd_I9Mdhxfh;ykw$m)cdAmN zAeF|lTX$NWeGmBoB7Kxz$_2J?lzpcfE=9xlR9niIu)s+F-z7$mvP_O6-joVBy*X4u z-8b#@urci+uc0tV0)`H=*2tD$X|}kYe?j{2^D|Z8yszoBMQpD%oOt!4dxQpU@wm1+ z>D`|*2BcLG`=CTEZItIdEFb{eF(D zq;N|JVZZs6bE%vS#YxaRkUlpB*P77==nFyda+A=Nv8| zC9RdUVv91}FQJn?;1);Gk`_M*7T8H=e{WYF>KI*1KLpGuYs2=KkSIyb!Pr(L21@P& zsW4iNh4r}=8hG5eLI^>W9j*w)uCJqhhhVYO9sOd?c;k`^Ho4s6N%)m2c zvXxK6HH2oLEJkn#++Ywr+)o6);|;stM*(RT&dq_l>x&xw;$d% z_roOy*Asl0GPwTD0K?w_UT4^+!nWjZxWJqYtGR_I#*f$t10TuolUmg>mNCG9F&+2Y zHr%>m@fFzD-+uz=ZGlt}HJ|5oCbM%YN{vU#BvVG-x343TWPbD3S+4aK;LCIG^Gu0R z{zSJTwZY5PRt+xvIkn>YE+@coCK`7LZWVlIU$;2fT(W?YoDqKfx*`@FrY|yK%%CDo zhUt5Oc}M9J&Nvi))F8!L+b2@X&vSp3O&wK6dO4JBC0zXAKds{*SEz}Z&GRk{xF}M7 z09CESNip4&%dv4Qz_tI)Wtnov(p$bnh&<2_H1H7~U%c)c0S?4BeAfKEn@eI_jLV9J zE@})Y$@>rBq+^B*U>9tar3NyqsXx?2=)JNh)UFsBJ=)8R&F*lkooAbwQYxH(x7S3J z_ofVTw_Kd#EO%}*|Fq`WZoaBdC}%_!l7s)G80OMwIUzLtEX89X9UF zZXAvyoAhg!Ih1aJ{T`i2_UE$L=6q4Hf|EJDQ3dU~{9q|)7HAwjLS=vry-go}J?$O7 zphts}>=Iz!B*}aqgYE7|LHXAamG(^9sO=-`72T<<@ZIQ|R0ur`AtqiphKw1WO=wrL zn1}FJ0Y`?H)8w;i9edr_&-k3w@;jHWxOXaDV}gc223CU{8(zI|!fMw7E9yi<+0FqE zgyU6lC*xH$fo-?I>XUS`2$C1ME;Pgsk2RGE?A(8lQuZp)crl*Re55ObgS_!;u9;RX zUhvBjg&`Q~i9Q-`mC~O?EOjJQwf5 zvncA~!ti?&B&?#&j5bmEGj$bJ$(v1ew8SqONRiP5DJJeFyPAJ>EmE2A*DfL-KDXR4 zrGl9yXoK>~nfmu}Qv-)?)e0!KxJ1#f2i1Zs867eBJ#;VX;>$g~#(YXq&ynUC(xmrG z!G$b^wk=9^XoXxOr!4h^))iNBUY-|(zO?5t4u1otH^nn)MX`IkBjv!xj3b` z$&k49`U`9OJJ2VKYnJUUOWuY(fn%ps=Fk1P)q40KUL>~cH<cp_ko+uhF_$H2*z(Yy z8sDeRrUo5{2zAB_q-s3kPX#zxjjg1h*0@PjZCKSoWNLp-$t_a8e9FpKk}{_qmrna3 zIINlM9pJ==aT%}B{_}Zu)Vc6Ok7~niC7-`T?#ljep5NE1WgaMp7weH+hHH{h=I^gN zXl>Yg(z>=m(of|?;$x3(H+DCR(+4g$CCukgYY6D#^G-RP2rxxTM@1T<%xk0YPHW{w zhLIwDasrMC7Pzf=U4~!lD9|@&mkFntLHJRZ{0&c_A1FNOxUZZMpGiN#7T@PogRGb$ z{{Ue%v?}|S9wnFA&(BGOdm{HbB$+%*Xh9s0c)cTF0i%*hdf;=sXO^4+^qt+dRbu9+ zl-4us@J|w?2jlH10fEyn$KY1!Jc!Cks4_f^h=Gf6Ddt8^B=SORGah%ge2pxDN_^oa zn$!bW%2ePffH(uHaL~7ya6-S(#1j~DJ!Wx8g+fAvD~dc0pEB8pJ>GVppq+(Agy2zs zhR#H2<}TrdGVj*>U}lE7dal}Bx#ZcW=dB-ok+`H);UR@rRBcS3%GNLs&ajrt{)o5|Hg7(8K)G5g%?I@9}`MZ+sc zC&i5^Z0mFd&_jE-&8)M+>1wAC>({Rgt9jWGZ0JG>9qXL6cSUYCY zipYvJl78&*q_;v!064b|=|(Eq)<6NWIqY@J(E54B`+E*nDOkd`;a=>Nb2mvh5_eIZ zo}m{sy=7AsS{C2g!oaaTI^6|G5HY)8%C&rg7N<{yYON$h6Llc2 z_eRW-v45CHtmYk6YE!OBEOo(?2J|FJunN~MR}<6V(XXnRr)EO1-vANN*NWMxdZmG~ z?})>AO=0_qVCXXSN|{2iyFnQJbMWVF&>w^&B_s_sts2G_P4=XJZQqyb{jtK>NygB7 zH~(Xuq=84WKa~XwGt8aFQlP|?zm^AJ!(s2J=z?5}nL(0jg=1Ug+;&^I+qwrO`CX}M zr*%&d-u8w^z&et%lNhqW(S>lsv>=&WdY_7uFH*)*3Sp5 zu0R_=QPKpy<87u^V$CE2>@6kyXpap^K2d>|HJ(<|1PUluHRI`4ZvSxBcewIt4<6$V zcss|Qo%Gq=WIOd)Ge};zvs2ju3flBx0Jixx3sxZ9P{&ap5c_)~_*o54as%%to4NHS z{(Kxi=f;;o$;+@hEw-Ores&l0klz7FEZX=v_R%5dl@rc|U`bWp;~|{wuK=9r&;4=F zKw@3XNyT=cdeu~L^rW;VM>q%9-d8pIxbxu$kq&x0;5pC^0&g5Ej#+Bp^e4TbbOW^}eF^*uKF(FgZ45S2ER^|S4F&6n2~{GU z@>(B}0t~x5To_d%%_LYdp7h^al@4C^o#ERqjC(E!=*F&6Z)aZGeh3YF2d2Z^d;Zm4 zln`91A+NX0s7u1r=H68r6%}iUJcdITR{%Z6LBPAV&x|GgML=&MD z_sZ#2i;-}#Oz@Dm=wD7X;>V_=-x&-Tq0T{rI!nF zs}SkfrE9Rh#?nmL^2&o(3uUkC2GUG-XINv2LZOT{FE%RthcT=L#kB6WnM>}B? zX@@DpLcXu?loT_gQM;;drPaURMF|{wh3DLvU{j)>Sk#MBH!o)5*3P4?W7ZaSv2k%A zhDo~Dl0N@t@2fgWGPbeLIA)+ZV9$TXe|(Uc62d(?d&XbQ%%#vPX)-*`V=W-5R*P`zPp#mtCKgjus+$^Q~sIl`l_ zU?^Pk_du0I3XRiCq78SzP#Ya@Snqcp7|p9r`_>mJnHyw8I>~`1fs^`%qKeQGm30%O zNP8WqW9H7H!ki(Q z^f2dpj|M!jfHm2VGBANyy=rANjENc%I+rx-%G&M{uDz82S6z2AQE6i*IBjFk7k3+? z&l9$Ms!@_9g$yfOnJ0r4^O_V^wN;#wt8K|5l|`RJt0$55~KXV|2WP zy8$9a5{h6M-eK6iJ0X|_N|J(#H+tF$bolLptTpy<*zq;%9(Q%4wxia|qoy$Bn8Z%) z@U#aJ=wKI@RgD+_Nom!t&X3Fv;;GHowA~eLZk{@}80gx3Vh2+d>SIsqd{zP+vQE6V;5|yZ!s>zKxrGa;d0sphIFPY zQ=#GQTT}aF%ZOGA8%3QgX?-iVeldrHgxQ$u9j)tK&IQ z{|S-0-&JcvOm&`044Sm(JoFKO=--z)q144Ab#ZB=A(&7ggwL+GKQF{e=(jX0OMn`MDau~zOIyeCb`b;0{#e~mf`%1i2e)DNPU9gy@(Ms_S{ zMeq{5sIrh7tHjEEWJDUJA)^^x=SBQdI!pG^4-Fso_=0!|#Io>}&@=aD_H z7-Z}8a#5UybvKxlWg)`Isc1|yd5T7S6guN3BK=v9jFqv*&~*I2$_%i7`*TSCJ0_Tx zx6PRjmy~4W(_xxBb#!Y1wdD)i?MmU1&N`(5Fpk)qIIyTpj~5*7^3OW-RCG+3aoTlL zJNe$U!?dtSwQQZL`9(#zdj_D+2YOl5V#XTf{6)u)kg({g<2bcjelzP;9*QBmLxgqp zD5xg%7QGY9$NT0jT}$wu#Dd3AxHNl_32nQLPqtVI(}r!&W%{a_APu09XP89zp{8s? zV@QG@8%Qt(|woG8FMr9fzN8)QY zC=O&-1=hM$kJ^C)=U}wgH(zW~NGt;YuaT~^DhjcSCVu4xS}2n8?9xO`Y*zA0Z@Ysh zE2(Fs>bOzXQ>H+SF-zr%XSrz+K@Z&^`I8p@PqTbfhl~`;?q8p2QrH&2lo-{lk5B9E z8V^WpBJS2-%WfitQ=A@qw$XjsRcCaj_}$zyFUR3gmfJPQAc1aYN7RcjsT$}iT{>o# zHuShIVz5T{6Zi95W2K*++tDo4v)LaihK9@DLLN?jb}i&{b`qe#{C*j}z^c@m!|#~K zWJZKI20k(_?#&kA$Z3iec-I+RW9cHx(e|#AG_U!ZM$LWpSwJ5VlJ2&za;xWB>J5Xd zc1G-Hv(Y<>z_8h#wZ&e_nH~#ti=&GTiOxsnOP9kLFPv0`vyT@DwI7j$T zbW8G|TTQVU33!36;~*(C>n}|O6)fK9!Dthb{)Hn|lA@{|{GM-RYDnoL10*(N^eEu> zuV;hKtMs(^F52&QA5uqk?i`18bz7rcEaTh$r08vvFQED;SXH0iD)^JnpbwajLnP&) z7n`hA_c>eUd4UX)P@vk-{xTPkj^^ZesKZ zgNOHHvVFW9NfGvo>Cs~9$_F!fVyxgKQbA_tI7LG}_nw!9K{Mh5!HtQV5od8+XDKma z6jEWwu8i1(799%nuK0Jcp+aRm$}2|44>dy;qARBlR`Di*$^`TnAE=3PuhU9bDl-u| z!cl~E@6RbuADJ2#p_-&l%jV$J3PSDs#_0^0DVgJNEKUo^a~8!(=}`%`$UQqK;zOdO z&2F;m`fDepHiuD^cSfy!G76V}pqik#WBa!;SZ*s9yy0Mv2=vWYbuC);yiX^Dl5XRZ-d*u*W ze3daW^$frLe2cExyEW8O>DM7i2+KuliJGb|;lC&Asx?lbqCT@7^r+6IAYxs~mNYRuG` ztO)nin%~C#nr@@!YM(5d1Y!+8_m?NG^qwwwBdBt3( z~pTzb2`5aT4k9z)`IW3&XS&0=X~bdM(Za(X}@K#_-G9rqX}-; zDh%76W*m=ZI8F|?2oDP(Z7a$O|A{HxYa067`|i%Yco^^NaLdKea``^xv(}bx!^e}q z8oeUUGfj~_F8(qZ<{sT4tcnMEG$Z3a`g(Yam_HtBJyLUQNTg=cal0dMgJZe0;1F^( zn4qz(Xx(uo;BCy|CqH)^plZ5eMLp-{Kfc{~^ZI(GC(upO%8!ge+}O>K@Axiw=&AjU z&SgW{qd22K9+HyZ&N1T)PVz=fs4f_bw8xWH(tm)9%QNATLi&jrZxm&XX~hrh4JkjC zZ1=jPmu)WBykwGw6}Hl{{PcotKkg(pOxIa^dE0$=45!3yDTgA zc;#;7{ITqN>~;m{cNyQ?oR{lHyehyu5Dgd&u>VuC`wSfg1#H#`Y}@x|dUU%ii}2ia z6S5YVEJsXuZOhRy0Ra*3iMl8f*00(7o27yb4^_-dA^bN7+BHHdHUg!W zA4N~IT7Wv*yj2k;$}lQ>B)T2v|9$1qn|@Ed{I~4RJ^&%PM@h~CG}M4y*Gg`H8k4C^ zLkdn26)^)!0Xa&0==Ke~_E7AX46XBpF#cCDYT0VIX%DE0#) zmKvOo)HacWqthP*my*8@s-iAGXsTsC0OnePPS2`&MOiB#QBD_Rra6oX zwv(2BZ0HN1US6=J_Xt`qFajTLf3-3d-Omp^3lbj|3DQ?AjP<_~NQ z!jsftO@KA9}r_` zuo7}ZJ@~e|QAzRZ2llM-*RrYt3>0|#n{;KcW8uv7E`SY0?V7btODA5HbbK_RRZEv7!zM-H7&7w*)&9zx!A&`xaJ9+qC+2)Jg<|iv5f``4^ zTU)|Y$dQ#HgJU#lgul;RCSzmDt>>1;^Cx>|0(}Qsep~`%#&#!(ev~5-a`JZTw{%H5 z>NC0ems)d+T$(TMU%ATu$T&L|MXs}!;A}B8Uplk)Mw#)yqE;CwS5lCgR{VbcR{uve zujXcF)~?6Tqgq@QC*I|BDi&!YkF(axk7@~`YB<)RHmxSGBR~hb0ToVKtZs?70EU(f z_y(6Z@Lg-vYOdyMdLy%VITP-8I;rU(6n`(~CJ7UXKsL75(;N0OM2$(h7Y!F&^{)DCtC`kSX%bipH~-%|PbejC|T))fwES z3@@jp(capIeU894Z)DP)3VRmS;N{@{cE0^~6r z9^r2tMWy)U>Q5*;vFMG+nw2R}V+IoEWA4t$(%~ZTrW@D~i!nd_yOtRuubth1{{dDd zZmH4n0qdzwUMA3%&tR{PYHe>#H(c`$NF7jR-gMnXnpZJrvM}L8QxU|>p43DLM@%PY61|qRGWdtF=p(`j@oz&@F64{INRw-(Y%-fQu_6~syu#iH3BGUcOLe$x!n)LHG~l$GA&1$Y zTI22fMplok|Jx8D8+`jmWEEP=8l*;tWlCxL>jxuq|>y19}{bPJ$2CE)Nc z(*ueto^c&~G9Wq$Bdj*`pdhE)-Wk^rZ7VJoUNHk=sQ1R66X-J_9jT*U1o+B zKtsbyRj`n{J>c_!&}c5iOWXm?aI?4M)EhJgZM(j0%vBLus;M8ouR@Fz!ep4rk~0jS z$ZIMG9-mXuZc>+BHak9TYU$tM#NZI?E9-bO@B63QnTP1ig3XC zGEhQ@-RQ40f5(kfA2pJaGmXHS4EXidegh6eqi^C?n`BM74 zZhum=RgGO$JVBc1OT|9+tl)Ltq?^r4vDe7S@i4X$j*7;r)}qqjNXmyyINYl1o%je= zr@pP%TCKFRY3B`b1({VA+13Idd^T&D+M$w}%G&OI`EH-PH$b&(MM2RA&gY7}_1|0! zJq93ere7iS97Ki`*RARyDJ~{X0YWuYFWVeN-BD5GJ-?OGcy5c|s80JdoD`&ePq$Q$ zk|)7>ndMo^ld>vxkUPJZnLoMYxpPGjOJykAQFPSn{zA)8rvl&0J&f9&j6sR)GuZD2 z)v}@Yf`{lM`(SQ2+Kl)RZRChEQTq`3{l{*LBZm2Xo|=3N_+NZYFA zE$_Ez+!#E;yBT?T@&jG!;wYcG!|GcI!o}q4qgPwOyrEO&>C<0GGsG=2hr`M$*7An~ z1SHefO?L~sHq>!+uxTC-fR1~Djqlj%>mqhGsi?Y!bAG__MU0cw#6Xvb-@O3Zo0G$E zS~mpv7JHHNb(y0eQNl+)v8EyhO90E}I@p=xB?|laJY~T ziRN9f(W-TKwJe(aPAj+yUU%+#$Fggx!A)#`0sWPfMo&~#N^6Lp;tPJTx2NRV%=w;} zZoi;8g<#wF;w}d288h;g&2`x+J(@c> zeB$P77>sDLUh%n#8~Nv~;ph~U-@6KXCG~yRIKT7h2)TMIr+h@0)bf@%&NdKvs0(7>pQ-+nFAHMPQ@Z z@|v0e%jSAy05Ql{ZO6HUQUP00&E70?+dfVytMR?mx95`eM`%d)7jfIw3tq)hyaT3u z)+ZnB5+hQ{k50q>VoadGbq;p@%SzGc()mWu6ygJ-V{Km4l{Xbzfryuz32HI7rZ%7X$*7Aq zL&%QxQ}5PkOx_IykJ;T3yn;@KI+Eb(8vYHZH)S7ftk4d?Ea{ zT53bzrS{$uRYJmxcD;IuB(lM9*u%LrW^( zxqEJCU+nYLB(;=a0%U=5BhR%EcQ!{&TA%u&r&SkhnaT!XZotn-?8cWncUB=g1|5MO~chG$)54UOkb$89t73{Zw@8mA>MkR)1vb&xH?o@y|UMsk#7p2%( ztYK-ELJUv)L#IZwd5kFf4ciYp%PeRaz zvmu0>#wWvxG&G0cZu$)x$mU1_{>rm>9aA;3T0u@_J!v#g4@v9~I(%vnc9<`ZH#zkL z9h{VOD!OcfbSQz7d(_F*LP$8OQ{ye!M=sc9qC{M9a7bbf=9g8C&elJ-x zXN+&$Yk|b9KV92X_giU-pcXsP;aV_eAej}bmMISNCCH5lC#n*^yKp!7Gr7MMTwjK`5b78u z(@5AE58Qf9BFh@h{_7q%vtUO1$o>ge8j;5>l*JVgt2s`PgebSbLh-1}?Tk^pwYLY+ z2M=<^bHKV1WrH>I#1T8acxWz6!($`UT2?B_YDO~_s**`|??F$iiFuUO(Dy1++HN7H zv5F(ilg!8s6(S#8%Mr7MPwTTxt1EGo-RAV|t3Nqxb4VJOgGgpX6u~|8WU@f=vQY>s zwo9*c?0m%o^9ag1Qe^W>_@ImM*4F3F7}oy|?Z9;;w= zU^SY%?u>pOoQ?g8B7#X8x9y+$-gzM?r(PhsXXnQLw*TLORHXLqD%$-?S=Ss|A?gPe z7i#MncR6_TuzjAfbN+=K=06kQqD=e5)3mq@(Kb@RY7jzE#9(4^@2X_baP}KCBu)p| zcL(Mn5=Pe5 zNl|4Lk)nrfD{B4NJabKWr-AaAZ23UD%~kTal9U%Y*#K2Ez7H8$5S8+u;X_=gqh12Y zye=Pxx^i~2yy1WlN2#Vk`bPHl0gROc%E4Py$9&zt|ZQhH}k%R(zM}AyFkO zvm@Tz&}u7cR3GZkkAcvfc1~i>uzluwO>h;yZr%zuZn&bbe7EPG&t&rMEY39yZX)F` zVWlD}rJ#Nx)Cz91p%#j~>Yr3e-ou`)I}D!{a8yq=eYN&^)}SLChNq^iHZQSfzAUQL zao&q;eUkCqe+I9yd8>^ih~=1$A+-JteND&prp_`(=NY(VX4w}qn6bk)T0_U zPmW_0jTz%gvdLpl4Pv4p>E~xPoIvpX{FR~9K_zw?ca^IPc|m(x!f25FMwd-`s$Vqv z0?bidEL)95QGe>)+Q0X^f-oX!mDV(m$`%;iRb!pQ25V~XwxTHOrDOQ`egorcs_FY@ z=|T4<*NG|(F%k{+Q8z8M6i-g4m5JRNafhkR)h>FfiD~htZJjvts}pgT!%UCmqE5V6 zx-5n92@w-JsBGm~;yo!(KevTlLnCj($H;^8{+g`Q=e@|jt3O&qOtmclH{+wf>! z49_)m9aZ;6+SYv)PIBa9)EF++vE(1#q2XdjWt*8ZL8}^$sM2k1{ac!6uD6i*Bz;20 zbL>vrtW>^GlM(MRS9)3cSX;kkW89^PR>h0btiR#$gCC*dep#rx9{pbsZ^>*U!_LL? zGSU%k-8yDvvI><`3BRM8prMmsxX?mU-Onc#lV^v~e{h?|iKpOhQ)H>cXgf@}cebLK zyqp3MEgpu`%|UnA_`-E<<0(F`+hLQa=L+X9Tx>5IJh9B2S`FB#tfl1${y-l>guN$O z=Qv53cnsMnbONibB1wU|^hnFAU=j@(?Vv2{WI=^ggW0=(FC4<{v?}6xVa$YG=NBa| z8g&M7|Ex8l{9fOX3^GVx`REHLuKBO7fA$W7xpF6d>^iojWwmiuBjO{Iag9tymwg#E zj%)eMzRwf%@b_lPsQ+wtB7cwW!LHv+le}xe3v{TE9%n4QNtrUCRHTFjklV*sBzoDf zdx$5{vuJm7&$~^CUVKQHDx7epzi`Wxgi#x=erJ=sKY9=~7C7*c;e!^DX~w#C3(5_} zM>j{{FnH4D4vU+ULBYnEeg5D<|Ee#}lHC#rMOiEqqrkp}X8*%-^ zp(cY@d=y`K604d}HpGox-1scHypLcb@{yh^b1xlosEa{7v8XC`Ht*c6knU-fEDL6N z@25WT?cP{>x8(VlK@K!pPu0`aHDU&}Yx$?-->$XBwP_!W39?vXQoDSXuY;wqp^cBV zWbZh-+18jgBkwBFqsXz`+hA3~gd9s8sbM9Kke?!O+aF+UW*C+n=M29Ok-M7%-e+Ut5Y z-u`oxXXn}8pkK`0WwmKjZ^LOwo7Gk#T0(Mn44Zdvmi0TZ0J^{qqG?e@DYbgHQ(Nrx z#M6e}quWPQr1F9)Y^+>!Z~+2|=XJ>}ganc7b>lk#M)7RIi$=HC+R zuCe1fpeWsSBX0eLZ#iNaT$VWEx$M8N)t0GE+$n|)KA{cu=&#_blp5ym315AA@vK81BUbv{1AvT znplAYJ$CUOzyn32LJ;wCzc z@gYg$x;nnxC)cA{9AB~jR~tbOjF)?cdeGZU!2`Oo{-?zTo{SC&z@BA)?fO3jRR8)3 z)QlhwQLL&$9)K|aEoch*n~>`;i4FeMVD>*hA=3qaQOrRJf$4vUIa*+R%B1CH{_R&i zL6IjxB!@BF+H|J;Tlf`JwjuFCOVr2yvr_H99~XX@{(l_Z2js&ZcRM4j3VsCer6{K= KTP_X#@P7cw2A22$ literal 0 HcmV?d00001 diff --git a/docs/img/Pregel-Paper-Vertex-State-Machine.png b/docs/img/Pregel-Paper-Vertex-State-Machine.png new file mode 100644 index 0000000000000000000000000000000000000000..1c6cf458dac3b8218c083e9de1e14e612ec8265b GIT binary patch literal 30060 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 HO@sdrQdzv` literal 0 HcmV?d00001 diff --git a/docs/img/Simplified-PageRank-Calculation.jpg b/docs/img/Simplified-PageRank-Calculation.jpg new file mode 100644 index 0000000000000000000000000000000000000000..154d651855dd10039e0fa83d482f6211a214b95f GIT binary patch literal 68037 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 B84&;g literal 0 HcmV?d00001 diff --git a/docs/pregel-tutorial.md b/docs/pregel-tutorial.md index 874f9eafa..e6531fe76 100644 --- a/docs/pregel-tutorial.md +++ b/docs/pregel-tutorial.md @@ -18,8 +18,122 @@ Pregel is a [bulk synchronous parallel](https://en.wikipedia.org/wiki/Bulk_synch As in the [Network Motif Tutorial](motif-tutorial.html#download-the-stack-exchange-dump-for-statsmeta), we will work with the [Stack Exchange Data Dump hosted at the Internet Archive](https://archive.org/details/stackexchange) using PySpark to build a property graph. To generate the knowledge graph for this tutorial, please refer to the [motif finding tutorial](motif-tutorial.html#download-the-stack-exchange-dump-for-statsmeta) before moving on to the next section. +

    In-Degree in Pregel with aggreagateMessages

    + +We begin with the simplest algorithm Pregel can run: computing the in-degree of every node in the graph. + +First let's load our stats.meta knowledge graph and setup a SparkSession: + +
    +{% highlight python %} +import pyspark.sql.functions as F +from graphframes import GraphFrame +from graphframes.lib import AggregateMessages as AM +from pyspark import SparkContext +from pyspark.sql import DataFrame, SparkSession + +# Initialize a SparkSession +spark: SparkSession = ( + SparkSession.builder.appName("Stack Overflow Motif Analysis") + # Lets the Id:(Stack Overflow int) and id:(GraphFrames ULID) coexist + .config("spark.sql.caseSensitive", True) + .getOrCreate() +) +sc: SparkContext = spark.sparkContext +sc.setCheckpointDir("/tmp/graphframes-checkpoints") + +# Change me if you download a different stackexchange site +STACKEXCHANGE_SITE = "stats.meta.stackexchange.com" +BASE_PATH = f"python/graphframes/tutorials/data/{STACKEXCHANGE_SITE}" + +# We created these in stackexchange.py from Stack Exchange data dump XML files +NODES_PATH: str = f"{BASE_PATH}/Nodes.parquet" +nodes_df: DataFrame = spark.read.parquet(NODES_PATH) + +# We created these in stackexchange.py from Stack Exchange data dump XML files +EDGES_PATH: str = f"{BASE_PATH}/Edges.parquet" +edges_df: DataFrame = spark.read.parquet(EDGES_PATH) + + +{% endhighlight %} +
    + +Now let's walk through in-degree in Pregel: + +
    +{% highlight python %} +# Initialize a column with 1 to transmit to other nodes +nodes_df = nodes_df.withColumn("start_degree", F.lit(1)) + +# Create a GraphFrame to get access to the Pregel aggregateMessages API +g: GraphFrame = GraphFrame(nodes_df, edges_df) + +msgToDst = AM.src["start_degree"] +agg = g.aggregateMessages( + F.sum(AM.msg).alias("in_degree"), + sendToDst=msgToDst) +agg.show() +{% endhighlight %} +
    + +We now join the Pregel degrees with the normal `g.inDegree` API to verify all values are identical: + +
    +{% highlight python %} +# Join the Pregel degree with the normal GraphFrame.inDegree API +agg.join(g.inDegrees, on="id").show() +{% endhighlight %} +
    + +They are, as you can see below :) + +
    +{% highlight python %} ++------------------------------------+---------+--------+ +|id |in_degree|inDegree| ++------------------------------------+---------+--------+ +|10719232-7477-4189-9695-4f08b7a89853|27 |27 | +|470b6c69-41b3-4f08-b01c-9503b8face38|11 |11 | +|757efc82-5197-4d70-8df6-c887a636c1c8|17 |17 | +|0d07e249-d46d-421b-9de9-64fe388ba9ef|8 |8 | +|8ab3818a-f8a4-4cf7-91d6-e049e54342ce|6 |6 | +|51263d00-e0d0-429f-ad62-66cb9ee6b236|22 |22 | +|bb13c447-4c53-4679-abf8-62e894c3f063|3 |3 | +|8843ef7d-4fb6-4eb9-ad73-54c76083c955|10 |10 | +|1fb4aa84-bcdc-4ae2-b4c0-bfa715f87603|2 |2 | +|91f9eb5e-41f3-4d1b-9032-0554f0223bb9|7 |7 | ++------------------------------------+---------+--------+ +{% endhighlight %} +
    +

    Implementing PageRank with aggregateMesssages

    +Let's move on to something more complex. PageRank was defined by Google cofounders Larry Page and Sergey Brin in a landmark 1999 paper The PageRank Citation Rakning: Bringing Order to the Web. + +
    +
    + +
    A Simplified PageRank Calculation, from the PageRank paper
    +
    +
    + +
    +{% highlight python %} +# Initialize a column with 1 to transmit to other nodes +nodes_df = nodes_df.withColumn("start_pagerank", F.lit(1.0)) + +# Create a GraphFrame to get access to the Pregel aggregateMessages API +g: GraphFrame = GraphFrame(nodes_df, edges_df) + +msgToDst = AM.src["start_degree"] / +agg = g.aggregateMessages( + F.sum(AM.msg).alias("in_degree"), + sendToDst=msgToDst) +agg.show() +{% endhighlight %} +
    + +
    From 3d3b1138ace7b7e59e49f6c2ef2514a7a6868369 Mon Sep 17 00:00:00 2001 From: Russell Jurney Date: Thu, 17 Apr 2025 18:26:33 -0700 Subject: [PATCH 60/70] feat: Filled in 0 degrees in in-degree example --- docs/pregel-tutorial.md | 86 ++++++++++++++++++++++++++++++++--------- 1 file changed, 67 insertions(+), 19 deletions(-) diff --git a/docs/pregel-tutorial.md b/docs/pregel-tutorial.md index e6531fe76..4b55a87e6 100644 --- a/docs/pregel-tutorial.md +++ b/docs/pregel-tutorial.md @@ -10,19 +10,17 @@ This tutorial covers GraphFrames' aggregateMessages API for developing graph alg * Table of contents (This text will be scraped.) {:toc} -

    What is Pregel?

    +

    What is Pregel?

    Pregel is a [bulk synchronous parallel](https://en.wikipedia.org/wiki/Bulk_synchronous_parallel) algorithm for large scale graph processing described in the landmark 2010 paper [Pregel: A System for Large-Scale Graph Processing](https://15799.courses.cs.cmu.edu/fall2013/static/papers/p135-malewicz.pdf) from Grzegorz Malewicz, Matthew H. Austern, Aart J. C. Bik, James C. Dehnert, Ilan Horn, Naty Leiser, and Grzegorz Czajkowski at Google. -

    Tutorial Dataset

    +

    Tutorial Dataset

    As in the [Network Motif Tutorial](motif-tutorial.html#download-the-stack-exchange-dump-for-statsmeta), we will work with the [Stack Exchange Data Dump hosted at the Internet Archive](https://archive.org/details/stackexchange) using PySpark to build a property graph. To generate the knowledge graph for this tutorial, please refer to the [motif finding tutorial](motif-tutorial.html#download-the-stack-exchange-dump-for-statsmeta) before moving on to the next section. -

    In-Degree in Pregel with aggreagateMessages

    +

    In-Degree in Pregel with aggreagateMessages

    -We begin with the simplest algorithm Pregel can run: computing the in-degree of every node in the graph. - -First let's load our stats.meta knowledge graph and setup a SparkSession: +We begin with the simplest algorithm Pregel can run: computing the in-degree of every node in the graph. Let's start by loading our stats.meta knowledge graph and creating a SparkSession:
    {% highlight python %} @@ -39,22 +37,12 @@ spark: SparkSession = ( .config("spark.sql.caseSensitive", True) .getOrCreate() ) -sc: SparkContext = spark.sparkContext -sc.setCheckpointDir("/tmp/graphframes-checkpoints") - -# Change me if you download a different stackexchange site -STACKEXCHANGE_SITE = "stats.meta.stackexchange.com" -BASE_PATH = f"python/graphframes/tutorials/data/{STACKEXCHANGE_SITE}" # We created these in stackexchange.py from Stack Exchange data dump XML files -NODES_PATH: str = f"{BASE_PATH}/Nodes.parquet" -nodes_df: DataFrame = spark.read.parquet(NODES_PATH) +nodes_df: DataFrame = spark.read.parquet("python/graphframes/tutorials/data/stats.meta.stackexchange.com/Nodes.parquet") # We created these in stackexchange.py from Stack Exchange data dump XML files -EDGES_PATH: str = f"{BASE_PATH}/Edges.parquet" -edges_df: DataFrame = spark.read.parquet(EDGES_PATH) - - +edges_df: DataFrame = spark.read.parquet("python/graphframes/tutorials/data/stats.meta.stackexchange.com/Edges.parquet") {% endhighlight %}
    @@ -76,6 +64,66 @@ agg.show() {% endhighlight %} +There's a problem, however - isolated or dangling nodes (those with no in-links) will not have degree zero, they simply won't appear in the data. You can see below the lowest in_degree is 1, not 0. There are definitely some 0 in-degree nodes in our knowledge graph. + +
    +{% highlight python %} +agg.groupBy("in_degree").count().orderBy("in_degree").show(10) + ++---------+-----+ +|in_degree|count| ++---------+-----+ +| 1|43165| +| 2| 341| +| 3| 218| +| 4| 289| +| 5| 326| +| 6| 371| +| 7| 318| +| 8| 338| +| 9| 304| +| 10| 299| ++---------+-----+ +{% endhighlight %} +
    + +Here we LEFT JOIN all of the graph's vertices with the aggregated in-degrees and fill in undefined values with 0. + +
    +{% highlight python %} +# join back and fill zeros +completeInDeg = ( + g.vertices + .join(agg, on="id", how="left") # isolates will have inDegree = null + .na.fill(0, ["in_degree"]) # turn null → 0 + .select("id", "in_degree") +) +{% endhighlight %} +
    + +Now a histogram of degrees verifies the zeros have been added: + +
    +{% highlight python %} +completeInDeg.groupBy("in_degree").count().orderBy("in_degree").show(10) + ++---------+-----+ +|in_degree|count| ++---------+-----+ +| 0|81735| +| 1|43165| +| 2| 341| +| 3| 218| +| 4| 289| +| 5| 326| +| 6| 371| +| 7| 318| +| 8| 338| +| 9| 304| ++---------+-----+ +{% endhighlight %} +
    + We now join the Pregel degrees with the normal `g.inDegree` API to verify all values are identical:
    @@ -106,7 +154,7 @@ They are, as you can see below :) {% endhighlight %}
    -

    Implementing PageRank with aggregateMesssages

    +

    Implementing PageRank with aggregateMesssages

    Let's move on to something more complex. PageRank was defined by Google cofounders Larry Page and Sergey Brin in a landmark 1999 paper The PageRank Citation Rakning: Bringing Order to the Web. From 6eac398af27f27f8c0c5aae49541fcbd5ac7b6f4 Mon Sep 17 00:00:00 2001 From: Russell Jurney Date: Thu, 17 Apr 2025 18:43:23 -0700 Subject: [PATCH 61/70] Added some diagrams, sourced them --- docs/img/Pregel-Compute-Dataflow.png | Bin 0 -> 31973 bytes docs/img/Simplified-PageRank-Calculation.jpg | Bin 68037 -> 0 bytes docs/img/Simplified-PageRank-Calculation.png | Bin 0 -> 43946 bytes docs/pregel-tutorial.md | 22 ++++++++++++++++--- 4 files changed, 19 insertions(+), 3 deletions(-) create mode 100644 docs/img/Pregel-Compute-Dataflow.png delete mode 100644 docs/img/Simplified-PageRank-Calculation.jpg create mode 100644 docs/img/Simplified-PageRank-Calculation.png diff --git a/docs/img/Pregel-Compute-Dataflow.png b/docs/img/Pregel-Compute-Dataflow.png new file mode 100644 index 0000000000000000000000000000000000000000..defa15f3034b16355b7fddad666494529fd55485 GIT binary patch literal 31973 zcmb^YcQ~BEy8sO9f+$g=cOh0MdT&7x1gl$})uQ(jEr}3ywIHG;ELKf)QKENNh!VZ` zUY7TfbAIQ1e|+ct>)p#`pM7TLo_pplbGLc-QcIN(p9UWd4UJIkxsom#8WsW#4MXk$ zCh(@D*=-*9qV`e-d+E8^dHF&-Y|-Rx+^lSw)LbC;wz{?u8~@k6wo+(ln2L_Cz+Pa@ z7ZTQPF1(QYKD>S|?!ag?G$~m>cZfCA){Du?*51)onq|MSm4(UCMw-P?M3Z0BUD4LT z@p*uUtzLlEE9(HLwYUw7tPGQsp9BEF#nubLM7wT&GHYf1n_+SnvaF)Ul%W^ zG|PV>1=f7Yr0C{h%OuV#z+=tNFT^A&&MW>*oF5`4YG(~%666;Y;o}$LAycLz=R$)b`rWuD*rtl@JpJ-!OP2Cf{)MF*O%8F??V@#AszWc?2WC0kEx4@Y+|M>kiddx#J#H*YU#7694*T7`@I|A2M%{O_Ft z_KeRD;?5_)%YVPB|8&&U{D1H2;_^SeJ-u{o{}Dqd_d3#s`w6kNq zpUPcA(Zd$v<>v9q&CU5g8~M_~&CAWx!OfjX@EPwjCeHhf;};eX;&tH_;ujWVvf{Vm z7ZVoaVq)UdgjhSe-goBu2TfB`Le16F3*u^RtEME)0>I~WbhMF>R~8W#Q&bTb5Ec*@ z5K!Tl7gbbIP*xU_R}>a|Caxm%pRr1A*4{3*u3rBcYxBQj<^RvI_q^ib4$Q1%>*45Q zYop@f=EC%ELnIvkZ(KzGPk8@5*5?1lMeP3^%LgFCcTe{JA2#^UD}cA|AODB1fgk@P z8Mdx~IC$LieF@$ZK%CHCzIdg4*Ba>wtKZt%g2Uk}dv_+Erf-VWmX?EhUQyFNX>V_z-Y`?qyM5ObR8vzk@b#T<=}O7a z4LTb7-O1`@rF+h=b3s-6cZi9(%{x?s-_{q~@s-<=h2x&t)AM4pj+t9!BX26I$G1Dn z{>4*2`)7<&x@#&**LF_^{@#u(-4+kr++82#wyZg4^=|CnCYAND2x$-h*qT@nih#t*^`2@_Afj0P2T#UeD#>*-bwD$MGeqFf;Xq zC_k)WP0z~rw$||ub%{>}Lj4T<`TLfNO{4{$m0dG%(-4WOc6*^BKec)u9Q(Ot_B^3! ze{1i|&C(z#?p-7td3$x)TUR(T`6s1rHMM28q_XAe8g+Me*)p<;YK*HJL-mZ#e;+w) z>m0ltO6y*}Xh5Rgl>J3D28QHza#9ngbnM|gAmLreUjl_BJOu*7M8o(%NZ= zr;}UZpR$pY`Tgs<%JRGGn?MK4u6Y!yC#I^d#m(JwW$)U;(Qj(?W@zi`5_N~F_8T1? z&F(z+`#OK!mO8z4j%s#~N&NEb_tAb+;?4Kqi8U16BfR;oOCo%{_}1OL&v|i`%OsD; zYo|}Td;5;|&cA>Ej;KH^uI(HgT|^Ww)Yn!`PA|;PAB}HaMO3bYmLO-3Zr&A-Z?2!V zHU7A*iEzsMy}rI~YG#q%v|V3cZyZxUy?%#6p{PhmlUfcZ*6u$1*jwDaBO)To?YX>b z@<-hP9{^F{@BZKPI*O5zacTR~AY*Fp^m=IOuwnXED{)|?w=F0na(1LEF)>kETIS;D z0vJ}Hg0w?J^8u+T$-nZO*~un0O`dALd(>cyM?-Um)t8I2xaX0|u1nK4QHvYviGHc) zmPQUow0|{*mg%9};unUdn3znI)KJiJ=F@YRSv4oeJvHP>6D%L7Hx~{4^ErUAg8sXb zL4!|)K=n<_x|Fs;*nw>5=`~*F`taqkw6@3m<)`E)!^_qxc`rHn5{Vfw$nm4v6o4OC zl=x8!mcRo!20@fU9toI}96ua*c=-SXbotlvuM0B*=<=`SUzh*S2mdep|C;%Kk^9&3 zzsUWY`G1l7hc+|9Rq-_gZv|1vm)#$!#%~lX#VHJFbMJE2x88W2Fkl20P_?X61cWUA zrq5n}`Yb?A;}ykQ5Eks({Z#p8?~(yN*MDaAxG=(UdTArkC~ST9!Ws#D31_ zGl5rWo9_i4SGV|01fD1(_x719Nx-fLhTOiP8`$)0^{$;vSU5VZB~br~Qm>#4vyWY` zsbG%r!>1)M2bh_h6*Iz=5VUc;-TWnV$E1j1ty1ipQ^uMO-#G5X)A)^bijvg+mIJ|) zkV@x5@^Ao`+MmV=ryKP1pNT`Smx>^5x+uF_-0uAYK}VmjswbXWCFL52b6*Y-Uze+!%F`GvWGxD4&_Qw)Q;FyCd<<7{0GqUVl>Hr zETpIp;}Ja`oIaDUWzk!+*Z6O28vxV?AG&(u=J(EHjW}CykrxdCG+w#;22DM%^xl1N z*aD*?o*o4_%CepkOmB_}PbnE8P^35bDvGf!nUA(jun$#75_y3ifV{x@=+N|z5;V(N zKMTIQUv9*4STKi}U)MB8*b)FIZPOza8AG}yQ*=*h^}F0-PU-iPgJV)p^UiCr=qWM! zCl^TD6GluLV$5Q^`_Z^NfpN-|_XGQ5BE8KqLGIu&2+_9D{`OjKeun}xqDWx#RyHSZ zSoSVx2aD&74teL7E%RM82AilK7)pPSlBgJn*#9nf!Pv)&&Qdupz2iv5GHwmgRKc}@ z0N5to>-(LZRHa}@uEzD@N3|p$XG{IKb%e~FluU@bhE`Be74F#)S^fYdE@M+JsA-KC zDkK~K@#FA0+R2v=U_<61gWva|Igidk4gINZp=ijf;ci;e`1B5%%;k*1ve+TxXS+^3 zG8@W7(1AyK#V78hGY5D|n&5%m}CrzNSOZ!2k?$PIXXz(v!LHF(pg8!ZmP zhc`jwW#!-VWHJ1CF{a|!w$4Y4rODgSh=IGLa@E=>fuptkWNzf!07P2tLH*O3wc}5N zk>rje5J1YcW4zCJ1HZJ5Er{TGn+i=Vh!CP9lDc0c*mpx#2{rN^*!A0&x(tr4-s!v# zcvE2JtK>zTymZd-XQ;ZBz6+8~&q{`E;6utK_#8zZE*eJ+NqlzyaX zv)*GH;({`^!m!NtZoNl!mY=QWW8vvvgqQtr`G=|1?p)s3*eP?_exyf^j2UI8SRx~1 zuIjOq;jeBd>t$4VBK_fEW)KvQds(ZyYn!=$x?243x_n*AjM7Uw)OKXEYFtvcyc+9} zYuh4v?97ZRA)!g|>(0rWQGV+wNE}w|A_ej@rr_hTs`Ta03iNYWIu6OX(Vv$rnA8pr z4|#+a(Z70l9u-vF=R~eSL41PNy!xTDX;n zmH_ec0gp(dKrP(WvX2XCZoLqHo=DKMLnv|E>&L|4`_0Z-La2Z1xB}0%Ttq?n85~(NqWh~q77o&&!+dDV~F4R+y?B)>Zrc6^?dXit7 z6Or)HH?ZksFA|>?PaaK(V1lzzAh$rx_wR=CRc4blea?eMyIm(I)FUO^F(nmdtNz|7 zG=h(8h0f7*1Q1kh*nJ1n__gbrm_Sc?AWEzLMBElo?E#46lpDv?^TX>$r~*z6jVa@I zexI-D94+8eYdt-aWI=tUGmM#N&!;)Yzu3qG-Zmm;E@VzzBUDOJ%v>@G0WS=X|BQ{# zyOApvi$739l47d8fByVQbjh~y?niW+T@zfJPDG!poN*%&RH=mOd0h?;cC=D=48&=N zgKby3tSeO` zPX4g^RU(hDiDRCId5?Kld$pCvIXiEPBMoDUyJ_RT8CJq)rI3 z6%i;U6Nhyv)so*d zon=!Ua<4NC?)V><3O>eNgXS_h8qZJ@(}#1O&I}o}D?Z_3zf;S>uK7weOkLt|t;@u7 zAxlrFQDHci-97d*)TtIwU9IgLeQ$Fv5eLr%;AAY}zt}}+0u)pjrgfqtt;6KC!?8Qs zzdROelZ`|R)sC286jK-rlUlDBomw+XDu{e=B5|9K^QQ1{tMyBLD5k2IO4c2QfX&aD zBvv?2_fTb8MciZFagd3lkXL)+{&P2;nWs3aQVfWD{bjm`IPoduE-bLR_-w2Z_zeE@ zP6|!oEXtx!)cMuEaMa=J0_iiSJO&S=?!J($g3Zf@_fv;u`DIu{d<`Ch z{2jUzu~QFCK>cWN@;^@V*ajm#)wzXGOX=@pZwwijOCzyFmx&~z=iK(knw@-IO^v6JT zo5T;{$W_&98*9A#6Pl2y|28WzL~Hx>GU&Y6wZ~4A_|{ei6IK|}M&=FfsQejLI2zIM zGyt}ccyzQm{+a;nNDhXpwlUd9Gte23c1;B=P7@(^KE;TTf+fO6pfx6XH41MXU%#qJ z+eR4^Dg>Z|y)L?aI*tQJn(7!7C4^EGSmP6RN`jpQLiwMrR*u`gmZWjSy9`nCd7@}S zY}AQgQn!0?i916Wj}GI_)TP@foyc_)2`a#~j2c<)R&9lmcgrQe$D!me;;3L(wx!YF zURu`5HJ?+FeFzJiwuMN`|G}tuN!m}?qFW#GoRayhzk2r&&#(0L#)I8zG`OUrOz@4JMD_T%dUzqGW!Fd? zS5~in_GHIRy3N!Ku7TOc+Xd2iELcnaL7(0B-qJ3j zJTNO^Cn8|(a|Lts$xgT=c1%}Y1!yM&0r1T^=x!O14ZV@H{XEyezOTDw(49Y{5Dsr(q)J z;jAmnb#nBucp^N#&6!w1tLK5CP)Ba=7$Uq)0|OFe1s*s&bSwdtGxv5!93J6=L4_GN zAI(e-QsmQ`nHglTRhKw1?M^yq8{4!JO?HO(Vrk~QXyxjm1*!YP8yix!%8c^IS5rtM zE>P!P(r_%<$4;YV;bk})(n8J(j!vRBaSz>F=wnVx>5UpHl>j_uRKyUaUS7{1e+l#! z;-Y*S%=qOk{b$bIaQO-k+sIxy2U7ZMaE!w>WTW(2;5W#a(i42bUQ-i{W{hKGd~V+$1(=$Z1O%2DZOnt{fmp`9Xg+sJ9PDV#IR678bd zltlerg}@YvHAMRSnofpp-_qanj@G{hin1=XnicxxFPPBmpGHi4=tQw~s%M4` z-s~U7kp$w-fO5V<_FSk?lg7V`exi0}u?Yb&grQex`9=}pc^9^Kda*UafuP)ehVa_O^qXnD%(_gY|bR7^bSzf?a zR?nVaLTnT?3fc=1v-3X}@)I7cTq_b{Q!nlK(q=^;NOi}YJFD5RRw)LLc?Y3Z#PAe9 zptEKKlX-%#s7lqWp_0e=y;&|2*<-_&n4qhaqc>%Fx){+FKJ8&14*opw3=&=h`qmm4MttCsN7QOC z1Cp03Dws%lqNamPV6Cdk9Qce)!B_wdCofmGy|HtTsZn~Ybf{l9H5o6K0JstlhFdyj ztnO?{gaJ{~#bcG~&^rjqnIyn6P7xKR7j|wuxcb@sGZ6Qxcaq$Jx=HuEYWc-SLuW3u z*s3^F7wW5|anC6n3k5V}V4pzLz43z+Q%FYv-shTTO7JtvU) zU7u{d^<=vinUO42(=jqN`Y>W>TVSvAGT}#RfB?HU7*R?2nIr3w(y+Q|=M^ru=CA6w zfX9C7A!GbFi0IrH?-6r_jh+?ekVg_+YBUet$MTyQAlnlVISWC_2{k{)(-xrNBvrU? z-r#iF)>Ko)a6(Pg%Yx0(8y)s_=%lIG&0(D;H;%%G?)EV?%v%S>0L>bB>|aml3y zbVzOG()qH_CrYv^^~==9=GZe>SEIVL4=W6*O*Gy^W(7y;R8tJNOmM0noXVdZG`#In z%M<{-EnufSVniemdp1PS#wr6Ng0>m+tg7jk19w~b?|rgnvZgNFGQjrvsPKd@fl)W% zH{V}!Y$2Q?enNEe^%_fKg1@m-hIOSOW%BD_=-k?Xipa-+UVM){JtKXi|1?AsJl}oS z5dD~U>gl(RrCmxdgMen(D_dt@;ycah$@4ap{%CU>!%uJQqd|>gu|fyTfg)sGhqA% z&!mwXRAm)r{f4t4aNVokE^_7LPxKrO{ zJ-NdeN%q%vTFb`)r{oB31p3+C|?~1H3!SrPx=tr?A@x`GS(R(w)9uq zeU_bJPc>SY;~4YBiKlZrXsWUI<7$OO>{cr2VV2LW0iW{^ZiI$rTt>-1Pxvo_Q*pK7eu_(>XyAXmMQx354|qay{1&fgi6wrYJpb! zi_Vt~#@))^pM8L_xw$_V6R@0&`_yryVCj;(&td5jD*6qyA&5%i-!<d z-mno>arin;t)&PiI`nTnYBNTSLd}aKN+ovCxLby$G^C{B75{^&z#u7Q0 zqGY5^|MWZ{qN+qfX)Ld8YwPQbM@8DVmOXGv02X3Gr_?vlM8o`Yck87H2C#X$R`SKf zS5cxQ(4msF{OFLJ@xz@;eeJU2a`To(b!J`S?Nz#BN-hI`+Q9GdFFf*=fQKQ;aieKD zsmP!W_tq}M*L_I;Na4UqM|Grxq~gf1V))0Hu*VXvjA*90dF?6%AI3g+paDI!o$Ef@ z#$#}C`9t4iZK8GQIgdI0Qrj4V-_|L${QHI98CUOgu?GE6^4-7oqz0#}N}SYU=uI$k zX<3xlKg1|7xO=6Z8tF4@IrL&OdkgKmrS^@DKP^(hx&ed}&y%7BGe^Ev3^l zHJdB>QB0v}Rsxxez?@ORZlM#ko>YuCn42us;`EyQ@R^<7XFWKqIqyBYe0(r9L*{Fd z!3ix&>6Jd%XJFT#z^vX`B(SCqg>5h`51aMaPur`#9qvrbLCMM#6c-=Qw#??JE=<6Sz(8gW6PJIe7Vqdlbt^xcUnQ?7e(Nh)WIh&IgC&x-}j#u?9qM!!oBhMl-BiY?sYfeTdlaJl1TESx*9MUQ(AW0x-f)N^Aa zo-n6tPU{Q3`B!5RgD1Hm8V#8@q%Wl4R~opy?t(MaM`2}(ttp2FJI|DQkC<-RpH%lS z9#zwEpuu)abl8naAzO>G*IlFdr#AhbwmUDjyhwL1bhA$d-`;>^>MWWxM)iM+oBg&R z0e>gGK?AeaeG3{^O}}_|aWb|+G<+MahTV1)|B-rONQF9d|C7&QxqB`7Lhu971luGO z7u+;~#xqY)gIObWUR*to2GYVFdVj}TS8EX@L}vtqoy7+rgnI=Yiv4ID#-po=14%{6 z=)=+aUv1ws*BS>^FJrW9l7PQ*NrGDc##dgOY;>VRSXx?zB}S%}KX>^HA_{au@B|m3 zANKwdEtm&&1DO)7eV;GfkJ{4?Hqm%e?a@nfmHdioNo$QPCl}~vBRvfsfKvKxRwfK_ zUP!~ld7^Mp&jvBWyS||rOyYzLl}L)IM=eYOV9L|@PYkv#P3ZibyLnuX z`ziJ9vWE}>{RpwRR@KQ_V$&N%%REy7%#}df&ov7ksWEb*FFLcHfB$2-N*hw$n0dEF zZb^Y1w|@KSht=i~YA^}G@2{d_<$C#QwCh`@_SGz}G1=OwVVk^VeNn%-RbM6 zmMGyTsyT)>UEf*q{`BS@0rm^!Zy;;iRL-4iS#b5zHn#!XsM>2YLul}!`Oyb?A5G_h zvfGb@LT*$%sKy4m@F*IK=J>1lxFJV8vO|DIBEA}!8u*AUsun`$lj`j1FNRSR?-cTH ziAI1(@^t<20rle}I3!dui#y}E3ITl)v3ecx2N#^UaDEj##ENL@hTsVre5DvIyg9d# zE(({UYR1)6+8JXl!e3xt(^kO*d;6#b#)z}tIu&SYF4Biye`gHx?aC~SDPzs;u-DS{ zD>JIL8l=YItC!NzH#c{+-^iwG4BG^8hqWjfL-kE2d0v4`qUm<3Ej*<4U)d& zU$xDyz_oPw5f?TIFn;}S$n*(x`xE6uVm=9fmU-p2r3UiGPMC^_PjtLbCPgFpH}%3I z8SavA5%cgRmI!2Ka=-7)He7OFvicJ>pWM_~vLw$z-viW7HQ)#&Sha~^|N2Gak&6Ip z(OY({CgHgB6bTgnpxyWV(m`T8!g!}#JSUm za7Gb~w)^w@D4O)?e3kv{;m-;9P*m^s5R}&RO$rL79j4%N;|EsJEJ3X&_YxfwG@& zcsdL!D*W6_PTQXhIM;jImXy|GKDEfsS>krbwA>WHmd>ftZr*-3W~U`F-ax#};dGq( zYlL3(OAA-CQ<4VUvW^S)U3mP`OMHWS=%*UbGa2Wl?ik{`x4G|$!66rQKwKduhNcYP z`-1m%(%(mwmJ&=~x*qn#}&<6JX>L?&=BmG@2J@C(M7q6`@!`=OIVYIX+z zxb}Brr!LLcwfv73ep5@-CK@6JK4QcYR8VTi@Q;nKKe{Ok|GitD9qLK@M~z29#^#B1 z6RTV)n`Os_F>f!Yz4Bw|vlnKP=wMpYFGAE&9)2^qftV8s8O#-?u1_d{4G@)!y&dZk ze6+A38uji%mxWA(G(-KxRdE1}>d6+J$GKnHDQ!O-wtH~^ev&=$MemS3KS3p=!zD;!H!RFV6p2v`Fz z^dxI#8q@H>I|sJO5FA1N!gI;yz@Wy}y+itz@|N_ep5!G-j0)2r)yIq~Zzk~{U-^nf z2t84+lc$p1Sr!UfXS*fBL=@#@p+rQxX2pe>W>)Epzw}2caVoqA*HnK zG#}pO5^4h}4iSOVfB|XySM;J?lM!j@RWCYopA-as7&Yf~8+!id$Ld{43Q50sRmsb+ z$-i^U(tp>UN!7lpd(vfbXS3!3ss1>494tfY zklA+~jRCc1FCezO=21`#Jp~ ztBp21lE+>UKe%0RaP32TH0`hG=yoN}G1Ylo<;t;+KB13C6loffB;Puoa^_Xvv`2@T zdaAS`qVHFd)lr4DLd!5a=T`rsA@{8wdSz$6wX!Dq6itt2m|@&g-id&7zkrW=7Q@@D z5hGgxhq$-c?AUdoUNR0FMRuNct`?A@Pc+n?IIFsgqe*WF2iFuB8(QnpbF}*g34`Bx zDYLJA-FksjYPHFIGs8+Q!^6-*f465y*&cYzU%=$)g98 zU#|mpVNRrR&0I_O;#EOGMS^iAs(wugjA`kTzu5+%j{~7u;0$mc_O6m@64|c@Rq*C?lL%nZyw88M z_w|tCgTP$B7ZU1uJigKSDNSW=%9x|U^PQP7(Wtn%1`2xFMSqi};Ji?~d6?5N@Gdb? zjsr&dK}T*y?H6N72H`9h<%Btn!n@Ya7oJqYY3Bw8LQEN6MB+`?&59ToMI7w;1cQKP_C`K^;%r-js52x%| z8m+$)Xs|GdB#fdr47*^F+S7y2ItJmF;(|Hw>Ah61L;DAqw=9oahwO$A+N4fE{sVp| zKmj`4U|UiMT8eZ$Ki(L~7h<<#?Ru&9I87}(Q-1=ktdcVgtrLW2Xp55rspOYLufd;$ zp_L~3c7g{C&}j!QO8q#qcBgQ|B|+a>X8WU_DqOhGs?=-sb~Xs)6E^q*x+%vnTBv$< z5_4OX82tI2DK`QGL{vKc>5|%3mn9-6mGy5KlXmqZ2OLSBHw|duH+ofQ{dT^Ea-6Yv z)>y0blph}Rv^`yHehko$jE?dN%6tx%He2*D=>i&D^dxmRWHVWZG+0oJ*~EvK?a+dN z(#>om-k_;P&iJP$HuyGCk-_V)z0o{z+tGSBHnc6<<%Z~UvaBcAD+;|oR{~Ub%s4@R z$Sz=6`5`LzQpiBQO6l&o#^GQv|E;y{W&dW1WX15~&s%IIloyg0)vRLR~q)!71E2Cn?{#O6@{A7`t~A^@jgFL(4MHN#)pwfbFN>&0{xAD~-~+H>Z0HqgFLI-!95N$GsBJ{-m_ z{G~n*VF&KG8HME0okXQ>iSA({SwA|fsnc9+B&x2pSqEfd8aMZ(kT1m>1l3p{8Acbi zWlA?$?DL7*lmAAC-HmyKD_{4!y?TTCLPRhT#7g#AAtutf>?b}ckW#b2nZu37Z_~5X zOD)jmMSs!>6VxVLCuT(ZGdpW9CBfF&qHc+NwvDre@UyIBy0Kr&^wf^Sx;J1H!wN$x z_kN1+CI1Syl|E?0{P?5r5rJWB5f+@HD zc3rtu+2)k;H(IRgI=w-GHn?8AdW?~tvQNA%seu~&JyIr<1)(Aadd4|0@bz2D^OfKz zxv#Phi2Cn{+t7PQ`#wa=yt{Lj_|Ol}5Z5!Gf7xL$@GdDMl{&bY6e=D#`0GPoMv@1C zb34rjD7|du<+y$IW$-U0wbg+>ex~6}Qv!t68VwouM~pzWRZ&uCe7mc-YzWBmcL_B* zyz`;Im6do(z{J8mKK^Z2mwI0}9fsiFBtU<=5VE0ayuK0IaxrG1{8TwhzB|LaA`3qt zSoR`Wn)0vhmI*zXZF@2iEdw6Qgy8&@zj~^Bg9DtxG=smCEJ^3Qsy=CXO(Q1;jJ`*{ zRymGqk!jGPhb6Fm5IR4#y&5)W<=PkOuwwopuEzJ*p^pE`W)u0+aU8EuBb<@?{>_V5>gQ=+sj)O@qDQc;yq<*hlKR=3dm~;kAOH~X45gxP5 zs*cPCoa1tI%eX94s~0D^yE6Ay8^XdX%QYSs?uMch;nAFfh%1qyAv0%XdY!*x5YSUP zDWI%&$W$MJ_pp#t^cWHE#Nc+kLSxx&R}uhIc7&adiRz4x35T6}<4XBi#-&=-&6+N! zHxym8F*7?^ZDpe&ZmAN5q?=`IEyb5A-=ITZn4|K3`t@ZuDFFNQxg%gNYoWc{i$MV0q9;JuwdWC;QHq`d``wd z*=L7B{SajCn9j+#fiYYp{=h_G0*f8COG6CKW$~CrhnEAM-r4BFVW-YS=T(%t2rpvq zb$Cu7qQ(4pR{DEE5e{g9qbrsDd(@D{<^yHRJi4Dk`0%J)3A5qmhE4m~+)#tVp!wqO zIeV8~ufo{%W52Y!QAM?*12Z>agYP7#oZ-KJye6}sm)xyln27YFsVu?(1#AR2x>8-1 z6aZ0-h%gc8VzE|8)65@)%t}t6`B>9AvZntk`;xo3Te7~p{Tq60@|dLD0z;^7#X1my zN)B7KnLD=KH_1JU&DCn;#|IIQr-ZE{X%zyx%4i>fIj`F?Ye(uoiatB-im#--Rmi!r z$@4}P7ArWPwOm?02)?5HfHf_F4YOUib5g*mmQPBf3!L7@L|@5u!%Bfp3Yz0xCHb z#Bso6V&lQ{AbZ)ZP`kDGVh2idjAuibmZwe|c7M~?#^6`x%T&y1cFnJ6L32V-hV)AN zx&h;hlKPuujpMb)gP|b=zx=CZ_*+jm3ypH4gCw>K(?aZr{5K2J?92l4-xLLr>AGf! zXdeqLA2TC%i`U+R^2Q7YRW_+8KK;5Z%ZdRCH1p{4-`&96bH=e1C5)$k*(+?&c!N*; znZ@A#$6JASl~El2)_QI`P5u>U=D#ty&x0J}vwb(enU~vA&YkNW%lvI`(h#27AF!wt zwAc-1&^2$cXD#@2{>tt0iPv(dE9u!#=J}3LruM>3(zsZw_q|B;MGP@QZ>Wg52L z7*%A%+O{nT2x_1qe|-ce<({>OxFJm+FOFs$6Av&BXPt*eQ3S3XwgrUrgYpU%bmT?{ znr~LFV}w`rRJb(6_ge>tO_+KUegk!>Kp`Vf%qqU6z;*KX-XP2mBMJnFH9^Y^Y|CbR zxW8wp>xfIE&wh}~S>PQJK~!7ADjgDaY&5pN1pBCC=V)v7S z4^DCwb!EWdpT$AQ8;fI{KbX&6EWbgb9^I21kp21>f4{Z7x%&yhgC3YJd_QEZJ6;&P z+rJeRr4dQPnhTW$4D%s>lbnF)M)aEewlB1lt_=cgg(=#XC6}$v+t)`bZwWF1P(T!J z9l_vBk7y}q%fuzxejP#>Cer=mkW_KZ)SBonSD)C!z;Z71Zu$6j?gq((U|KbN#zkXsFH+ zvXj2Os1_|SLOQ#Jxm9tk>K~gNoJd+Sm<7Xdh})q<21XDTbv*G&t573 znTVG}9^km8y2CK<@70ngSr#p+R8_0&gQ4e{xp&y`yRm_;kOqDmGLtMlhnCMqKiuay z9zT+~y?|@lIqtxs2xNQz99=BCrE{Cx&-M}+U5sY6!vCJhf33(Ab+mKIIKRkJ=u5ju zfhPoTj$u^#Zyz1y%-IBi__RGmfwK0t26jJ{@YCc*M&BjOTRWqc(Z<8(j~iDg#`M9l z_vY13{c|7hY>2w`9~V;C9Un7}cDgx7etl|bPYF7I)}%E)d_87biTCyZiAq?C!#|FD z-%bMlt4DL00w)=|k$TM2GA7Cmio5y!Z8xHi1SlI$*$z7$_LVeoTa)&LEdN?hR@_^k z+ON1HF)-mBT@{a_tu{w_ET0p#4DDyt?nUs4x&)PC8!WtVyu_Y6@k0gtKKT$mjQS;% z;M;H6C-wX?+9>mMD@t=AHru|;6IspmMtI0|UX~ds_2isF-_G6l*<0QVy~U$Vos;&; zwg~9^aX97s@%Q-;7gxW7-Y7z3lP^KkJW8eFwkuX3{S?)p8Kz8+ zftn?;Tu*iXcQ%k(erw1i4`(RDO4<|kc|VkeSLD?PO5)v)KlAhs!&goLV!FVD*p&rN zIu0qS+;0lFe`yyU#3JPSQ$~=BV-FM?Cv^Wo!nah@cdD&;a;KbpJMylO+>DODR@aVt zMgtck*o-P)z3;z>@en`Uh=B*qV5(EE6qEJE?to)$tgrV)1BVik|NY8|jB-FiX#Ku| zn}eoP{{06CV>-IM?e|PN04uUOkpHG$UDf>Dmg&XexRLZgkN9pC;_Lnce*upZ3r6Me zNodZ^$Wq6kR4MzBpj}NsNn?cKC=NhmhJA#qg=OWMMlg!h#+jw1wxLlo(qav0*+589 zf^P&KxM(C)E;1*wUqmf&=>gYVBtZ5U8q&K81)45%P2*bv=rsiwLvqYOv7StU3%}%gYa|mu4R99+?rOdbxH;`~LeW?dFNgH`FrW=R z#Z>+q-*3zV)Tk5yZc~U6;XpjAzve8-bR{xhVJ6vEcnjR`=>X8Q)mQH3eckF#*cRT# zE47M#-`SQJEch|wz<~RU#$~KjL7EEg(gDeR!)hLp#z^kwe&IL|mH*4Xc!$6>APIOL z5i~i5{?)e%D>*S{8M7vCI@Or_22jE)oSJA^`4fKE_Q0~Q z?BtMSi(QMvHS;Sp?_SW%TLkckZo z^W^L`<7F3nTm?Gb(u7s*{dJdOW&re$3xz8a9-b1?j%hS!$NW`ZNL;%avF)6TjzbNQ z6ZkXE#F1b?rdDwV=fmGs)L2P-33xSSC+B0&)?p%cq zTAT;2Ao{tB%1XTu72$pK+NJC{pm4V8MkH|*Seh!TJ-3Ro<^cZj#cYsB08WDmKU9&< z?Aq8B9iPCry)AFAymCkU#-51q+$h#v{Cu)_B3+VjZ|>e&`v%E;MGu`_7cH(dPfd+i z5v^%NDGe>mfS4MJgQn040^(09Q)7MkJZmTief<*qmrPz! zU}`z2!|l%Z=(tD$ULt}fH6{H*?p`nHbVK({pO6E_?#f)OzR$JzZ*EPq{@1GdCoIxG z^J<(|EB4&gCD(?e86++86@bf8z?AO|OFlKMs5Yxvfa)|r@qfr$uNAxXkBc{c73aRT z13IHDTX3X-j1oQ4W-IZwgm*n&wK*2tXgIUE?j?0G@EX^^vzuN^miOc2y92@F`+Gsj z0$>8lg+&+jjJun+Aw#dXY-!w}J^7ioPuc##=_G`?%>`6oBB>gQzkXmfW+}No&~bzI z;(E8<+a9ji^=lX*pSHR#83X7M@iCysA%oKKjfkUs$tLZ7gQX@(fC(WHh354;+VfL` z)@g*TT}(AL^2ozw^Qj~__7?uVvKG;??BcslKIb~C+J9hqxi{ixq1eWKwUiFXP38eP zCE7NvdGYT{GuE)lTGkcdT9XDWbS@k(Jm4db4};cMItQ z_Gb;pCgSd=rT?#6P!o~ildEw=+lr4#XV78i;_&_Z+S)l5&ey$smzT_ozD-({$I3oy zh$@+r7TCWw(k-5{c%Q_MqZaeq?BKLraX867v?s>s{w~P($PSO*jJ*MfKQjUe6cPbP zB~X0Q3#oqq{)@Qn-afgMY4ij$oBt;LuPa!|o?u`L?$$pC$DC#lHD1KB_nEh8AV zdCRKGL0lA2smd(vpAc`@`($1CueF!oezO__A+dYDF(%sXG)^k}UM=b{Ay&8s-j)I8{`5C%uh|;jsi-$(-9v6>1PI((8 z4y{Cb8=!&`iPagVb=8-UBzn&&o7&;M1b&;7zWuAFIFh8YWkXq6_fEO+)sTN+Ix-77 zXpk`>tgNv^M{_|`0<-?@A=;8JH5Ig8l(=#9_pI8hxQ3+nq#? zZ?E<-gjWP}KW3OW)xGH;0q+%uS1Wo2qsR)xqIjh1G72hPE-Nd!o~e19aaD+hcT~aG zLu>g~cfP96ciR^c^&2a|*;Qq{!LY$?>+{X%l}Tek;#B0VKiDWAH&6OnG09AQ>?G)U zRxc77P14fbTUpt#SXre4RQXas$7NhRoMVwbJO-gYHk2(jzboSj_yAhKO*$anS#Qi% z5aq<%is9=$+ps7L#f1UlXNAS*Ze_)O)Fe;?pYvkT_ZlYe)Ijt2@ZNKw)8g}D+ibC{ zPfX?~vck&(m10VqJ6(}mC%3l+*x^>MxqB&N$B$}e>c&Dct!vMmZLs2c5$C$M9o87LSSXe=D`k^$TJA1COrvlUA_*_lu z%bC+kfK>~dr*=pIQjHt5Rl z#5C=1j3O&@_SJ!%DVdb!;U6_~Vsdv-!K_&dnp;y-q-XmU&YORjmxhKvo(W_Mq~OkS ze8e{Q;yJOiRsfQ;cb~8`A&1CcxI+4374B(BdF4NCPHxWD0IT_t~WIA|&c!VM6vfK`D#kS%v*D9W9<2 zyfVHQorx7Xn1L*u43++PFUmz+uxf+1%!f(uKICI$);~O? z36|OUePw)G>7;OdL-rSP(#w~yG<5>|GQ+ohYJtsj`!qyeq@IS6v0`fddztI#7Rsi8 zQzX>(hF7TFj_sigt_X3gJ9&%<&f}Ax1)OVmNpl$C z-e`stQ)u9;o7#e}>S@Q3;S-LhNHBE?mBT6SQdr8iHsAA64qOXXwb~En%4w(D5hd>y z1x>ikiw4ne_j+TBsQ5;p))74JW?Z~~iih{5+T;7dZU5|T&0mns7_K`x*$i}T;^Z?> z^I4Zu)dmPIt@fUHOV(Ut#&G-8xcOF2r1wqPWp9?6?!zb&O*pP6EM*KiHr`Pnmg>4AhMW2(CNSK_1hco;ajI3LDz!s_&X!0+wgF&9 zuAxejFrGg@A;Dj!p0=&Y&-Gd*72vJ_cU-`=|4(yY6&6?1rHcfDLm)_S*9H>Y-3h^= zaZ4b$ySsYEY03AqjBK zRNd4>4Y!A(UkntZPx}07kO&^-u4`SU+0-tHD|W+saX7is-Qk0J5I~LoMfbqZKf9)W z0ac2};mcEf_K4xoCz7*d^TUVCzJgM4DYMMqTcb%q#RCh2C8xRQ{2SWSP^Bx z`N_T$pXx=)3I9p%E0N~|Ez@{U3if_49L`OVtwJwS=hO%RO#9bE5J1TY;IU~>3P0W+ z4Lz3X_2?5a#C>_~Vbqqt{a4E!z9 z5BiY6W-joOFs*m%cRK5@1zbu~-eyvTk}olt(vZn9Bo>EktBbM(h&Ze~rL%K=ot>TS zwwFI+@&R5(BzN4;WvZn(>nVBCRY10Vps0lBU6$=Q*5qLaLim^83O~gVsq}#Geg1`d zM&Oz070v;klq5nqRmdMWSw~>YhgV&9<>YyKy^ZTC$}GL?^kL*3j{Dg2{bkXWVSg~> z4_#W!<|r{^8|3m87d}?jbQ6J%M-v3SvkV)f&8g&HhtJ%-ilG{adda44k*38hg}K`! zxI_7bWoob`A(IG^_j@-AAuCs-3hqB$rdZKzLKu9J%c+g*JZ=VM-_AzGzKS*~yfdP= zE95~WB|s#I=~=X>V>E+S#tUNr7pf*J-giXdAE+*V9+#&Kt64wW=XN87RKao;;KM>% zzN+~gkJ!%z2RRS6y#jT|_OV0~{o zHyL5}PM1ot{JR*69~>kZwugY&uK9jEMznJVwn$clH93dPUCmIx>G`>-iqQ8L%VQ1Y z6pZyI*cLu(4&%%F5XE;vgg=3f?Uj8S;GZ!>x)Pm%ueBV>m0LEvWsz%~SS>pRYKE03 zO(wyZ^0b0s0suIIEMG6%uO+_$TG5TeTKz*F>aG!QXAjjBLUqckzIFZ4VxqS!m1N1) zF$Gyyv)2*#uP57~zl7p;sK(7lAKeAJ)Vjf?>7S=pO7uN@znky748< zrq(T|pTfn^nLX%YYD%_RGKh)YlDtb3p9T?f?lB2YmG&sW8>>ndwSA?e87vT@r;#o{ zc5#Mrw_&h>+HE`orsQu@f{Cg3X28NMEn_D4_@hNc1aOK__Z+pFqfTBE!;47B82xLD$27@Oc@oZHqh4LPO;^a3DBj#Jq|Q5O7516R=z?w z$}0|#ywMW*?R2C{E`Tt>C$Xr9s)S+2rk0egQLcxIUeK97My1mtR9(Vn{t66ZU&q3S zEgDNsx_Kb8vE`KqVgQFc1jawHESG`mds{$SjcEnLce#r=g_XyoRCTUvV8-jpUl6OQ zp@2f4mK?C=0Vb;sK%7+20<}Yqlm4GnNmyLb56e54#;d6)?KT}&y0&gP3hadSm!*~2 z=3Aj?+pym&x5i|Go|BE6=awoBp5WtH-rr?^HEcNsbu;T|!cy?46qUzU@<6`LNl$}z zvLQ!$fDZ- zwY6mi0m+NK#G?d_=Hdu+%NWtti3gTPFRPZPSqm}!s`ca@$E%R;J0Dd2+Uo-L`1pt>tL+e ziVG3r;{}re6V|`S)|Dmq-0L}JmnuU41Z8~Sbgfa(QD|MIofL0)iVMiA?@(8*lbJL; z1h$n62`v1k+kRi~-TskEbKNaqR-?WTO6+u+T?PYlULryH+1$W#i8fGbr`nCnzsULn zTBBz*DI+?r0F77}a=DDKQd}yz6=kK7_oui$26%<7=^DX?0i)>G70{G?az15Q=`C;h zi4`9u35!tM3@iDkv)Ft_)Mrl8mG8VF&97KdF-|3dUr=#Ut;84{NmqA=x4WNV%=Pua(^q|?4owMDwPZ$L z^}nwsW5w$V)MNzfgrv9#)X`~A*F?`((3$h)(`5y&Q)m8q2~4Ngd6ka*{s`B=m#POM zJqjr}Zkt*Z=a|jvDBL}gR*a3YMA654Rgvj+>rNH$b@qy5qS^tJ?7wKQS@TK@9sLah zH0MP~7{Xo{RHdo36GlN1Sx40c%topj9^u`;4sa6vLVM6LT&yR8W^_L3-X4XU9 zZp$>67~3;(6@`~mxC|EPQa%8$G@|pz1v`*}Syid3VBs=lZODxaNemX=gH$FS^H1UB zGX+YH`c1`TCo>nPuY)eS8|5e`=Dg~36)<4x8(T6bT{3kgtR5J7e!#EAQjE$Kt(LS> zhC1JOYTfCRVgU^DoE1JI8g7B6&!~;vcJp#0972*cW*}LgcGtZ#on=ac1O$*m@!A5Ig)lhA4Vb}Qsd}F!*i{F?6GPtaZ z_Z@OvY* zql3s=S&o>A@9&1vr4RywEzPA}vO9T}$LwLV7TCd}QZ^!sQ6C$;)^Sn)m`bLxEUZ8o z+ta|*aZv=;xN*uZA0U~!r7njoOG~{MH?tPjfKZpDoU|?Kh$I)WaGyyt8IZL2AcD=d zvFo7il%A$T9X4ojxmwd~jxB#GOj0u{LZE?k#|Rd)Y!7)%rjhK#EU?3Yiyzy=j^+y$F!F9-iem3&269S-*I8^Jj5A+TKo8MVba!n+B{@OVn)5^lO)0*vjBp4d zgJHiu#rXJull5J@r5?;1I7Ko(V};$SSzQ5~y%IekhsJW44s%q|>P*xhk5d#=oY>rm z5!sLl7_Qnmj5qvkrws*uHm#MM^gEg~Rygs)^r=te_e1kzQfMphNyZz81EE)vz(tR# z^IT|if5Xy9)mGR#kBM1YI=6%(!Q!vlOP<-N_f?%klnBFm_#<{bn=R3}aNrrXpxFcz zyl3YOKSvj1LfI;z#fM;S7+W4zVI*zGJdze;FZnBCl{|cTn|0~<2vl8>ju|Fg_5 z@wg_UQtve|vqarNi`Ia2nM&X8HFA%Y(d269Gle2kZA!L&iwh>FJcB!rwnBGzmzO*` z!$-DHbh^$^gu{En7rKBqa`;r`*ekD!mTWylAn7ht-v42g5u?YTe#Y(k(=r`ys7(9c zo6g7AzzS{#_dAtiAs^&5q)T1Nc0&7h4&32k^nrBC9Vbb6VB@aY{*5n;FTh*I%LQB- z9S>Ek3K6&(QL{im*N-#Gj}Z-Qr!b}?8{>F-;?Gruk?zCaM)}|Wa_?+4-~WB@<1zZ# zKYZNg=O0r<+43uekxT`#NDEJl<&^XbWG?+{ANIGz%e`SCWf`{988h14^{yAiY!j^m;mX2Xdga^J z^}32HxAE`Agq(@aU_|9@=@xKwr-w$I&0O7TK5B0Pvhi(0eD(wrA$5IsRHygDrT4dr z*@KOW6zV@43E`perd{XbDh)71;{3CG>lp7;wRT9Mw{mlVu|yC&P<2+l?IQ;RCMJO$ ziCkGLl+?3({(U*VRkf}Sn;#MePAa=QVq@2;Cv`RKSW`$pv!p_=$sXw8P*PE@igd5^ zx4oPr{_@pSj+orqUv4*yb`;^_Z)hh3Cxoh56ZB5~(y`6C#ll^1@qU3#Az9a8(>^ts z3i$}XNDq%&E|;R3Tpy^@I38>?Zhn@>uUNSJLFU~3#jWHa!3<8WON5Y9ldnRM8i}PK z0=_-Jbea!N&Um!SqeYLY-v1#ICB#PTO~AxUjFZZa&&bH25^XU#Fzs4gL%R+6&9Yvq zGxk=C-x&nU$<1xE2>3D4V}#`^_N$31As&*k6vBC7bSNF0@yEP;`|@@D)ybJ~%`3uX z;g!nSnvp)`5Zz(AW&(5|r)w0zzC*RiHl0?$XLUW2HlukCkc+STO{~ag=O38ooFKdq z&2}@~yjw^^gWD1_dk@N+l$A*Q!S37uS#5d7I%TSL03Gir)-SiR{XFORU6EF~>lv9J zo1gU7Q~)MCiOj7YImBY8M7cC+kOiG5Cfgyia|js?6tMpw@5K2@iE`mdzjjNbE6m(E zR|q7Nt!l?(t{|fn1G9uL6IM}d$MS2dwQACLy^*u6(POD`?gl89T(oHRHA})B_{Q7W zaV~ay`aX=xti$>b=?QT6%&3Rl^yHzt0g3R_lVRp?kU9m%1EJj0iweV`YU@QhtMAyW(@H%I2;}~9@sD*)!;Pw+fE}@H5}v9Dk-}$ zA%V|&wQWwpv^1*ZDr#y>$|Fii!JdIGxSp}#R^%n%j|a|3Azp2^1!E?LpSLpWs_7l( z=JLP7-V+(rkcl|m@-)1qPkjYu#nl!2bq>a0Ot%$4BuU?neCOmD#;?v69t$Q>P#*bi zV=O%jjuG5G&-Y0aG?4?Ubdjdu)Ndi%R6{!uVE!z8{h(q%A7obeAm#Pv)pT@l|4Po% zAfAekfXMEL#;8?r9s>)$%B3ao?Ut?_1k6Bxd9TmnZc<G%J8b%K$gzhYfR99&nPk^)G5nD(gM#ED(-L1x$*8d;!IvY^tr;Rv zm-a*z@YBvP{o?Ty|13`{WRZ=lkXOt-aXHG{B{BJmtNGPl-)R^t_odD!GK1PQ#JAEx z?TDF=xpSF&W{&poT%%86hNwo)$RK#KZLuziNILTaFeNXXb;cJwoH4(9I2fLwg7pDd zwmKtx6@kv3FW2BI13|=Ma~-3J3mKW$T}tdYdNX$z7%J$)@F#=9OX)`L9!6ERGaCn( zn2XT@8UIqu`1E)ri4gul#{kC1D>7*?N?Psd%GXx^VKh?tH4=8NZWCxqucn4V8?8Uo zL^tz!1P~D;)I7%+`KmSLjTja3X7Avjs6)R36)G!et zR6cF_Lmt9AAkPQmg_kU%;DAD8<59=LnoLzPIv6in$kp>5pxKb3fH412 z6^NnS+#Kosu8dYFjhb~WO5e~f<-PGOy2|=7uAbnMF@Z5+z-A!+Z$=l68|2T6s}}3; zjLq4#_bHex;RvLIJ`DdHDh!1Of71=@1Je+?F{GpGpd=u3#?^=$iwhipkpq+8@e{*B z0d%^K-UU7dqz7ee^%6$tgcFHC@zwkCw^4am(X1-Q;@90$LvP;eJvrSo9K{TGELk2uQ8ZRr9l;Wb@+}I;KyPBWI z=7aCBonsx4Ht!!E(|SxxX#vHZq8PNS+I;4M>Gv`H7}g_CLg0tAo(*0e-U*!Jy77M) z(2iYqJL)Bt`%_Y65VZc7!T9I!G<25RLT(Jzwx08w;%Rm0{I^FJtY|v|TvV*+aFra# z43(*ouo3l3&(bBpZmBxWz9I%rSx{$TWN0$3S)Leepealcpf{xE@o_E$bq%-9+1^O0 zvbvv`2Ps7q8z8b8hT*)!+?)2tAJxP)h=n2+p z2dEKsHd7`3lKrG3Nd~b&d&M6|L=N08_Gp`UL%S_+cM?ksE$bdR$MB<=ahz$n{hTvN z42NB|BLbiI@rdR34oPsvr$xAEb{UEV2M~F$8 zO0$0ECNPFKLfhwK0mTI#j;u%!3Ci%KPIJR)(Z3JQHnEe2b-G&$Zt&B5-=J24HTk75 zLK+(SEi1!KI^KQ>+te;6a2?QPWnRc3^+kaZ<@6%g@vYh+a-}s?#rQb<@3*Pi#-Y(r z?x%c27!Wn2+I0sRhf$v|$q0<|NYy@1-AmX0`iTz=z%;XyVSD`)HpK>lhv43Ksf}L8 zjqGtcMZRlp--(e!N4?>_e20$589<&~i?nuT)+7F9g4M=?^YzlJ4Cb8FW`AP9Q z9{D&p;;{f&LEAcQjIAImWuh z!RZ4au~WfW5J`7-4rP53+^!osmjW84E4Jf7S2BTiBd_E}MVQ7zH->*XNSkj#z`ho^ zcyvBx2^f@bE50*-$lob>-iytMh3Ug*+#WLMhzjm98O_FQpL?SC5-|QT=N`>`d`Dg9 z=JtMMiKhvRhKnKvIp9lWny*ul^48I>UO;HvxYHp4QSa?|gZ8_emaUF7k>SURatVCF zTZ!Ak@rO`f9!+2ah7nPPqZ%qrS~Z{w*$>X+Vlf3)81BlE2E2HT4gh27#-;S!G7+C243Ya4jo}L23#5%CD=2ZPz zwjz@^-!jUB(vD%p{)S6Rz&wI0HnoLUi+{H>uEz@WuAZ zZjm0Oe4x|Gt1Z#@PZ=k3W0!yOT%gPv8+`wRtOnkYWM<2deqfvycA9VmVrECjxe;l0`w)*QsGQX>3y(8>DaRKklaP zRM}_|K5@{62v_Bg+t+;+dE5?n7PYwzS-rP>2nQ70>SD3^&}{#G&fq@GcBL43 zT;V(v@#Q>SU>_ZC6oN{iHx)kx@5U7a)jCk+JHT(dqx_k-B^l*w9a3uR|1jVorf5jp z>-p;`Y?W`oV1WTR>+D1r0oHl+xxUZb)$CLRGVX8jEsNXw+ZjbC=QQsHl1#O?ADZ~P zQ8i3}f9*{t6mBwV45yd#2>Ozvq5# z?z`d=tOTYRxAYmjGy0JVz%V2 zoqrbHqgs-@{&o>;f?@&Iv5OPhlFsPEK*vfH+IsH^jNIn483<%t6A$U?C}?CI7SsuL z|AUm*@xc}aA=a5u2QOsNSMyPM`5-~Jmc~-Ibv5pYOkVS#?BL*OPkVWm73FZ@4J29b z@!9+DP!R)Y!2hn44&+`6{$zNVaaW#^(TFt~_awJ^uVmELcspix!D7`U==&)5e5}~2 zZI`w&J38ua*xqn4)V^yO;(EG*dAHy37j5|V4zq}-<@@recBNiUt>? zxlPEIN2^E9$c~dU{1AK=6V}`6i-;7Lzkj5OpC+{3DW*}P+n-Bs6JuGbvi1Qhiypgs zF&+2Z2CKpgO-|2poh{;_(mj=U>1Xk`HCOQ04UW=X~qSJ}i6! z?~9>U4Is($yr|E#=hZ!CS^laX`;U~8un}v>07Y6(W>a*H8tCRIGcGd|BJpXjl_4;p zN{FXZ-|J$BfFJ*FVMK+P>W_hr{o1AlmRIlN{uJ;L8+oAPxaA7p#SZ`BNnK zd5Vh4yZ)?H{#o}8fw#Ott!(Jk#>vUf$%%DqhBuc$9&wFG(^)>YJ$brC>m@!Ofp7SZ zPuiDSXAYA|eH$v#m3b%;;a{s~2lw0KJH!wqlfYzDYk$q|MUPZe>tHO@f$kPb8UV|g z?*(ScI=SzG#$HDuRce;<=&Ig=p__=0Yv#qbMCWH#V*A@yw{rgWNF707N&r~Fp&ILZ<#4(B9&mMI3=vd>N=sMh{pu72y)i^Izc=tu}0z ze(i6Vka02OTER?oUa)xg5hgZYrJ@UPBO0?7;qCJ!GESzI2!{ z9gpRTjCpoG_eEch|GwYx=3P>|S>ZqGtZuHo*ct0jfHq>Ahy#s7yB8OY4mO9()E`ns zpRdp2OP@dIbW~roFs=BUX+Mf!Ha3)iFX!w#?tT=iI{TGmY~snElDpdDUz4scK#^e&|ay+Tpcg_@$=hoC5SY-S;kT$nsF{kzEA5;+^CB zaI{`@wcfZFUAyc!2IXSFhX^;|Od3Y?-5b)JvNrqb{nI!Dh_E5$ab9#`X{o)T`l{i= z>yXV?-c{OYb^lNlI=}R|>dUZyT)~pjn!$U$k4NmFqtnpvX;07Ij-R5_)Hg+B^~PzX zuzH@{U~nZxyBB@)4fJn2op1-%%V4>7d#xYSi5mWFt&hE53`RTLG@eRHT^LA7?MuCD z$mkE*9wue}{ChO`UnXKeeIsmQ0LKiCh`zdQcpDHRkv~ z=AH#+!y_;;@SsWovsGdK(-UTpsQ%*S(^WWykq4D_<@4W7MVnVI20V*;*gPBqHvD^4 zWmbbD1%&I?#WNAUc`L+LwRS4@vUsw<+1|uHt~#+lACO+8GKV6uA^f{4yUJK!*LR$5 z1eUi1O+>2!FRF5w_#zH96GgoImN2Q{&w0G6v4(r;@t5wqL-&mlBhh<6UXWWa|D`Bf4lCNZ^U#pOuDEo-|af?0-vH88`jf zD(i%>L^$Vd$q3Jdbf!S~F{3>8B%as1^gJn2>z zeVR+3lBH45yAjIyA~cy9S{&(D>GM6$3u^|DN-bn{>rL3|yP4^Ss9yx)_W|>OQD^=O zJikXWw2z+JEHzjyF+)^ERCQI!q{(1Qw<{_J-(Jl&FPowBhp?T%Kpib>B70R)%KSG< z0372-;O^r`CT{>qlr3T{SJExz$EnH)c!7V#uk%T zzxaWJ4b#rZq*|;zNonXh(d{vHU9NUD4GcyhXC#jpCErJcj~+{^%K=Z5Ew3^3Ov8}e%*lGfIAF( zXb&3&JzyR!+Nmh@{B`%P>n=Su{{u2r2-1u4>aa?7wTFd{A7K^&8}U3+x~a;#`Ku|^ zSGus4tMLgWKNg19H^4N)qVJUi!u4tcQ|fT{gT$9Iglsk~G^hY@8!P~sPJJf1>r+K- z?xijGZiIk-jvmBGTrJ(P{>j4ML7$a9jXT+6Uv%|o*#GX8fXc#68Tx~nJ0t1X8?NTC zP>;_O#U#)oRV`S~lNm%$)jY4I#$KBtgrlts8!gyB$z~E;Jn`OA(T&J1@vwe1`Wxwr zV6ALX#qVe4vQPp3T+*Nw$dZYbEpV3{tv7hLbA5Q=_2i>GRu3_N2}RzUM_{ZR#xgSH z^#oH_G^m!$-@uL3%eNw!i38X6W$kqt)*<_;+gDBuG#E>Vg)9c@Rdw=LMEx)WpH_}K zzWeP8gI}7OMn_rRw`4iv3iYRvM)kOBa9IJ!eZ3Ps57A znT}4NRa)iSKK#XkP0*5Rx!MI0p$u%?K}bycZ!k^Z;b7(t2eV{2n3cl8p`gRTOya+v zi2?uTX92K}f5Eidk*m|KSatNbyu8;*zZ78K21u)~bhRMDT-}G~LdGGB)$Kx`n^pY6 zN4!d~pSb9yD9{}yubhB@YAeIH1B_C7>ZA^P{>lU%1sx>Po!8AV&H&;>K^FtAar_Mr zqkY7DCW741?i*D(sXr-M$@#~*zJAg3U-IGLUi5^6b>@G1_R_)sEcepsm%9Je>Hn93 z`2SpJ7s7;kmb$Bp*EeO_ha>{+M2&1M+Q!Rkb3y#8pU#tgNeOAaR*Ixm&xoT$TvmM_ zcb#~C?2qawVM_qf#c8kpg;pE6H-pT5gjDf@t76?zh+MJZ|M zCc0f=Dzk&+-A`Gd>J@~`{!vFOf@>s(plJ{t)HS>(j$%!f$^At<_fa!B8O+eW>{ z{lxWiv`;8f>5^7;xUQx2D7erPTJaJc{E;7~)jGmCV41$10M>jD2NlvGOo^m*>J7kh z+29oKjh*UA^F1{2CZ^Ey>VeL1NgH{ej?7m78@PzqLRuKbgf#|ZISQ-oFOagJEBoz! zYqt}c%{IqKf-_3AAI-29ZGrlNMg^F9lD1$lyJQ!SpwP5=%<~hkn%M`Mo-He$o7~n; zVbAfsS?AEgo-{X0tsV9sb{Yef*0QPfS<3dGaJzLG=KV4+Z6mH%99r6JquH8mN9aJg zjZ~93FArK5?0D?Q<@OxBWFik zNGMTBG}8y?Z^^}8B(R;n39CMkMorf*_X2hJ53+3s%5YMlv=lH+nyf&N#bjH~`U4$g zi~(c@dyVThfpMiLoqoINhs-kmr3NSi`wpn7L6dn{rWMIxtA0}j4OCcz1`XhI$eew1 z8n;q)Yt|<(qkgYzzBD|gM<7ofhmv(+V#>^3ZDtm1f008kt$tL(w99EKLPJO%L5Q2; za}*73d$3#;zft?7lV`6;9=Th9JdP0gX~9L_O(3%_8o6|m_gFr| zhO1C-+^R^Boqf)P0uOZjbBbOwS9~$eGsJa?=(D-@6orZBon@3{=q1XBw(kz+rGY4i z!i)5Ug0g+VvAbJqHYa0Ted@onllJ7--MQ7cOT0sq*fXmBM7j*ir6IFsX~>>D5iD>@ zR~uL0n?T=2LpV#fD_j{EE4bv82+CZ3t?#$y;!0i(F|J_f$EK1S>!E&JxGIP@T@`mh zddGi#oLg5OY*jUfpLG3mQS0k{h+)01CPFa^HIz_)Z)Rs?G8hL!jG{=`-%V^KTt#RJ zP?K|AxXgK7BvuENRB{ZNeY@BIR;%esnc|#sO^GhT;b^nHj-Hmmf;Wk@Y0U6l8(BkD zBuEOb$<~sa{bow7fGGu(zL);o$3Q&(``5yfI>coOIi6$Tn2Ji60mP&T5dv%Vcv{r_ z`R$TYD3cWP)jV8bc>y**F=EqEK^!%63KBGZHEDpt{SBM|EL0YK(-Om5 z^Tg}$NdOnRyrGwa=O{g=T;R4Hq`z3eTPnYv*&4FclxZ`1FpK+|m{s%5*FEUxIr>-1 zZ}#Gk(E%>H&O7_!C@d@8%$eEr*n2X2=m+f!E(2pXCH-wFtVKtd27jDpk1UX`m$go6 zzW*9B_T4Fw`77&**j>}Xuc-Pa%(Y$JR89si;Cr@|dE%_&QO#Roi1HXQQvVTv+L6+;O<1^KhHwvD}lz&s1oGEP0 z+;??;{B)O;FT29I=$%eC&bjc^V#73c;6n1n?J(2q6DO2Vm$8%I`I<5W-^`@B4xzPi zIhy+rx|*&;6YEOj(QLI+)a#;>v{#a^*m*g0lA1~&Oglr3{nMESVBx0R$3#=6aUB^b z9>4e$xHu9OPE5fT(%C$1pw8T^MVYWCMD@iYS*ttLd20LYz05`S*g;yCuA`O}8;rKK zn@unl8q&uSuYMW;=G%YMjl1tL!XHH2Am3`8YR;-!B{0YJ12K3te0p&}hp~$P;$J#q zPVaB3zVCMOK8ui9hg>QVBQxglSEFwUo0OjPabBBmplpJayqrm0#?40=_`<%N(%HNw z*kMYR1}2Pr{pKTUWuYmw^w)t(J#;&JW65n`$pwRBA}d#)Uf+W+tNz1>Po$%>oSXF$ zEq1r{YpP}B@9Hw=8*mNFb8-2vuu!5C$*DkjrzFy~_?KM^)KZJg-fO>Gj~un``Ovqy zEmp`wz>s|+NE4y7z;$C#e6Pn6k2Ek{nf)Y8kHr21X9#}eFA`j=AlC?HZrP^$X;4%+ z2?ZOi8W42uhN1$nX$?*=ZTjPIH&L7p1{Yn9cvSFl2GqJZ5QFqbTQJ%r=^9zXWNOy` zOBFzm`twEETy3B_^uNc;-DJ{&)TA1gEZuL7giJ1E((JvLE3wLo zQ8CcPKj9*7;z$G1h0*;A-HOnxz4{nP#+hh!fi;b(Y38Lb5S3ZfS&gwS!(D?%xkGcl{dc%(&?vYLT25NYsZR=k(89YHBm4lFBNRQmYgYP zezc{q$j#gtSq{e0ItoqRMLX=;zufp+Nv|H_x<-QdE{Oi?>hyf)cApf`#02RON9W)Y z<-Zj<;>s32DW5qgD8t0;1YnXGh!f29ha^N05@1k{O&b8YyUfrid};Edk8E zTjXmtxFAA_w`OMl4dgOPnI~Iat`5A{C1qmDh#FESyV3i7g0Syn z(Txwn8jAH;_BzV&+r;!^Q1I`bv&?_bi-;Z&`cyyIAq$dp#Jn~S`>d`-Qdj0DV)B1# z@L>3sXwoN6HB*|0q1s*%|TS-_73^*$SZHg zIvuvTu>bcAK+@65B?U?mn@jCpn0rY1X+VFdC1SA&Ip)M~ zirVuIp_|LX<2x9)=-7qP>Gl!`vcSyjG(}(w^p&au7BoYoF-&1=7K%?Gh}^3f#}_t> zA|n!}H|~~lr4n|np5T9)qjS6oNSe#^^v3DdRX2(4evPNQ;-<2NX1z77`puj5Dg>;i zI!zq2M`iN4bn=as$D7>#aP-KxTEl5zX79tHbY~4JQ}7=LfoMF1O~-A<7KKQUDk;PEETB~eYIN$$nygabwpXftcXm`~koL_nG|bb`#F1dgdTfL|W(kK1G>@)G z*Z;XG!HJik{TIHL=NcKqL}3{b6G&n!7H=mKoTrdQ(~ZVTJ@YTua3UO}uKvsR%W4!- zLSpwuf#OQq9W=qc%~ET8gb8r7=fV%Wx~up_gN1ACc@0KGuMjXUCVL&U(pxm<1NA01 z_jbco?c#tQV=;fN7Z_Xuosa+y)AV#EE+e{6|AYpc63Hk{M0EtJUQI0b63g0wH2>IZ zAGck!7ky%?JGPyp>&w963spJCR% z2_c-u#1Wq&U9g$3=+SlyEW8VBR|)fZI<`Eu#$Q3Awq}9pZ&NtYML9ndCh1XEt<>!y zFC%OnBwhaI7Rm(9l`>n-I}ajnI(r-3lwZsXV~rp7?7z%fBSP_w@nsl_i2<8e?(a$l zx~udhQsfm2?R;hl@j5fh)7@%vkJfyB4m`{-S%jSCy5%v|YvI5&h?M%d~n@0XZ39e(haZM5~@J5S($A=S=^mz znbNi80zH$@yhh5eU4i)QWUxmOyTT+vk?DkURoCX8VWjjV;N#AUHG7M1W@uOjXZ~vo z+Xx&;OAHbI4I^CZK%ig~j6ue6}h{%;WARvfnIM?|F~*|>x99_ z^5iZ$iNa56qt#UoAI8bf|4a>&X^!5Erg$yNC-3g1@yn%*a_XvC42_G0D{jrQTp9a# zag-T6IQkhXAv;r)jp-_tC=>675Dpmxrq()209hh_Ej}UYb5PN7`H@S^E&a5B3#s>M z@SPI`GTaD!^$`lW`gns85~}(Biw91Vm)RC6zVs%$5pdjPTnq?AQvV*w(SdBJ)5-KJ zb>{EJxy7Z>v@oIxyhz^1h%T&>DE?R+9jVQHCj2dOD%XkyiE%g->%Pwmai28GZLo!; zH+QL&=bNvyab3sJE-?bYDFN9-UpSI|P-A7c|JNwQ|2_EDf?*l|iDCRN!uf#W^k+h2 aI6ZgeP@-QCg)l4tPG0(xRHcM*(0>3jX)de) literal 0 HcmV?d00001 diff --git a/docs/img/Simplified-PageRank-Calculation.jpg b/docs/img/Simplified-PageRank-Calculation.jpg deleted file mode 100644 index 154d651855dd10039e0fa83d482f6211a214b95f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 68037 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 B84&;g diff --git a/docs/img/Simplified-PageRank-Calculation.png b/docs/img/Simplified-PageRank-Calculation.png new file mode 100644 index 0000000000000000000000000000000000000000..5512d8e1c497c202246c65ef7648846b79add759 GIT binary patch literal 43946 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 literal 0 HcmV?d00001 diff --git a/docs/pregel-tutorial.md b/docs/pregel-tutorial.md index 4b55a87e6..bc33a181f 100644 --- a/docs/pregel-tutorial.md +++ b/docs/pregel-tutorial.md @@ -14,11 +14,27 @@ This tutorial covers GraphFrames' aggregateMessages API for developing graph alg Pregel is a [bulk synchronous parallel](https://en.wikipedia.org/wiki/Bulk_synchronous_parallel) algorithm for large scale graph processing described in the landmark 2010 paper [Pregel: A System for Large-Scale Graph Processing](https://15799.courses.cs.cmu.edu/fall2013/static/papers/p135-malewicz.pdf) from Grzegorz Malewicz, Matthew H. Austern, Aart J. C. Bik, James C. Dehnert, Ilan Horn, Naty Leiser, and Grzegorz Czajkowski at Google. +
    +

    Pregel is essentially a message-passing interface constrained to the edges of a graph. The idea +is to "think like a vertex" - algorithms within the Pregel framework are algorithms in which the +computation of state for a given node depends only on the states of its neighbours.

    +
    + — CME 323: Distributed Algorithms and Optimization, Spring 2015, Reza Zadeh, Databricks and Stanford +
    +
    + +
    +
    + +
    CME 323: Distributed Algorithms and Optimization, Spring 2015, Reza Zadeh, Databricks and Stanford
    +
    +
    +

    Tutorial Dataset

    As in the [Network Motif Tutorial](motif-tutorial.html#download-the-stack-exchange-dump-for-statsmeta), we will work with the [Stack Exchange Data Dump hosted at the Internet Archive](https://archive.org/details/stackexchange) using PySpark to build a property graph. To generate the knowledge graph for this tutorial, please refer to the [motif finding tutorial](motif-tutorial.html#download-the-stack-exchange-dump-for-statsmeta) before moving on to the next section. -

    In-Degree in Pregel with aggreagateMessages

    +

    In-Degree in Pregel with AggreagateMessages

    We begin with the simplest algorithm Pregel can run: computing the in-degree of every node in the graph. Let's start by loading our stats.meta knowledge graph and creating a SparkSession: @@ -160,7 +176,7 @@ Let's move on to something more complex. PageRank was defined by Google cofounde
    - +
    A Simplified PageRank Calculation, from the PageRank paper
    @@ -197,6 +213,6 @@ agg.show()

    Combining Node Types

    -

    Conclusion

    +

    Conclusion

    In this tutorial, we learned to use GraphFrames' Pregel API. From 4ffa82a44fbb880ba061d4ec325e35d61e6549a0 Mon Sep 17 00:00:00 2001 From: Russell Jurney Date: Fri, 18 Apr 2025 10:41:56 -0700 Subject: [PATCH 62/70] Rewrote introduction paragraph and title to include AggregateMessages. --- docs/pregel-tutorial.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/pregel-tutorial.md b/docs/pregel-tutorial.md index bc33a181f..1e24b7453 100644 --- a/docs/pregel-tutorial.md +++ b/docs/pregel-tutorial.md @@ -1,11 +1,11 @@ --- layout: global -displayTitle: GraphFrames Pregel API Tutorial +displayTitle: GraphFrames Pregel and AggregateMessages API Tutorial title: Pregel API Tutorial description: GraphFrames GRAPHFRAMES_VERSION Pregel API Tutorial - HOWTO scale up slow algorithms --- -This tutorial covers GraphFrames' aggregateMessages API for developing graph algorithms using [Pregel](https://15799.courses.cs.cmu.edu/fall2013/static/papers/p135-malewicz.pdf), a [bulk synchronous parallel](https://en.wikipedia.org/wiki/Bulk_synchronous_parallel) algorithm for distributed graph processing. It teaches you how to write highly scalabe graph algorithms using Pregel. +This tutorial covers GraphFrames' Pregel API and AggregateMessages API for developing highly scalable graph algorithms. [Pregel](https://15799.courses.cs.cmu.edu/fall2013/static/papers/p135-malewicz.pdf) is a [bulk synchronous parallel](https://en.wikipedia.org/wiki/Bulk_synchronous_parallel) algorithm for distributed graph processing. Pregel and AggregateMessages are similar, and we'll cover the difference and when to use each algorithm. * Table of contents (This text will be scraped.) {:toc} From 690c723372dd5c63ca4b0381f99f74bd0b614602 Mon Sep 17 00:00:00 2001 From: Russell Jurney Date: Fri, 18 Apr 2025 10:52:13 -0700 Subject: [PATCH 63/70] Renamed 'Pregel API Tutorial' to 'Pregel and AggregateMessages API Tutorial' --- docs/_layouts/global.html | 2 +- docs/index.md | 2 +- docs/pregel-tutorial.md | 2 +- docs/user-guide.md | 14 ++++++-------- 4 files changed, 9 insertions(+), 11 deletions(-) diff --git a/docs/_layouts/global.html b/docs/_layouts/global.html index 4eb102c0f..d74bbd4a8 100755 --- a/docs/_layouts/global.html +++ b/docs/_layouts/global.html @@ -75,7 +75,7 @@
  • Quick Start
  • GraphFrames User Guide
  • Network Motif Finding Tutorial
  • -
  • Pregel API Tutorial
  • +
  • Pregel and AggregateMessages API Tutorial
  • diff --git a/docs/index.md b/docs/index.md index 7211a61c5..5e720196f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -64,7 +64,7 @@ GraphFrames supplied as a package. * [GraphFrames User Guide](user-guide.html): detailed overview of GraphFrames in all supported languages (Scala, Java, Python) * [Motif Finding Tutorial](motif-tutorial.html): learn to perform pattern recognition with GraphFrames using a technique called network motif finding over the knowledge graph for the `stackexchange.com` subdomain [data dump](https://archive.org/details/stackexchange) -* [Pregel API Tutorial](pregel-tutorial.html): learn to mega scale graph algorithms using GraphFrames' Pregel API +* [Pregel and AggregateMessages API Tutorial](pregel-tutorial.html): learn to mega scale graph algorithms using GraphFrames' Pregel and AggregateMessages APIs **API Docs:** diff --git a/docs/pregel-tutorial.md b/docs/pregel-tutorial.md index 1e24b7453..6465805c8 100644 --- a/docs/pregel-tutorial.md +++ b/docs/pregel-tutorial.md @@ -2,7 +2,7 @@ layout: global displayTitle: GraphFrames Pregel and AggregateMessages API Tutorial title: Pregel API Tutorial -description: GraphFrames GRAPHFRAMES_VERSION Pregel API Tutorial - HOWTO scale up slow algorithms +description: GraphFrames GRAPHFRAMES_VERSION Pregel and AggregateMessages API Tutorial - HOWTO scale up graph algorithms using bulk syncrhonous parallel APIs. --- This tutorial covers GraphFrames' Pregel API and AggregateMessages API for developing highly scalable graph algorithms. [Pregel](https://15799.courses.cs.cmu.edu/fall2013/static/papers/p135-malewicz.pdf) is a [bulk synchronous parallel](https://en.wikipedia.org/wiki/Bulk_synchronous_parallel) algorithm for distributed graph processing. Pregel and AggregateMessages are similar, and we'll cover the difference and when to use each algorithm. diff --git a/docs/user-guide.md b/docs/user-guide.md index 6197b0fb6..10ccfe981 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -698,8 +698,7 @@ There are two implementations of PageRank. * The first one uses the `org.apache.spark.graphx.graph` interface with `aggregateMessages` and runs PageRank for a fixed number of iterations. This can be executed by setting `maxIter`. -* The second implementation uses the `org.apache.spark.graphx.Pregel` interface and runs PageRank until -convergence and this can be run by setting `tol`. +* The second implementation uses the `org.apache.spark.graphx.Pregel` interface and runs PageRank until convergence and this can be run by setting `tol`. Both implementations support non-personalized and personalized PageRank, where setting a `sourceId` personalizes the results for that vertex. @@ -901,19 +900,18 @@ sameG = GraphFrame(sameV, sameE) -# Pregel Message passing via AggregateMessages +# Message passing via AggregateMessages Like GraphX, GraphFrames provides primitives for developing graph algorithms using [Pregel](https://15799.courses.cs.cmu.edu/fall2013/static/papers/p135-malewicz.pdf), a [bulk synchronous parallel](https://en.wikipedia.org/wiki/Bulk_synchronous_parallel) algorithm for distributed graph processing. See `Malewicz et al., Pregel: a system for large-scale graph processing ` for a detailed description of the Pregel algorithm. The two key components are: -* `aggregateMessages`: Send messages between vertices, and aggregate messages for each vertex. +* `AggregateMessages`: Send messages between vertices, and aggregate messages for each vertex. GraphFrames provides a native `aggregateMessages` method implemented using DataFrame operations. - This may be used analogously to the GraphX API. -* joins: Join message aggregates with the original graph. - GraphFrames rely on `DataFrame` joins, which provide the full functionality of GraphX joins. + This may be used analogously to the GraphX API. `AggregateMessages` offers a simplified Pregel API for a single aggregaton column. +* joins: Join message aggregates with the original graph. GraphFrames rely on `DataFrame` joins, which provide the full functionality of GraphX joins. -The below code snippets show how to use `aggregateMessages` to compute the sum of the ages +The below code snippets show how to use `GraphFrame.aggregateMessages` to compute the sum of the ages of adjacent users.
    From d4dccd3a7393195c4b3ee1175bef7557b378609a Mon Sep 17 00:00:00 2001 From: Russell Jurney Date: Sat, 12 Jul 2025 17:20:39 +1000 Subject: [PATCH 64/70] Ignore spark-warehouse/ --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 4d0a174e7..e2ed62f1e 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,6 @@ python/graphframes/resources/* # tmp data for spark connect tmp/* + +# Spark warehouse folder +spark-warehouse \ No newline at end of file From 90001eec9aa5e2a1d58bb5053fa99e914832396b Mon Sep 17 00:00:00 2001 From: Russell Jurney Date: Sat, 12 Jul 2025 17:22:57 +1000 Subject: [PATCH 65/70] Added urls for Pluralsight GraphFrames classes --- README.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 0079439c3..034beb009 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,9 @@ This is a package for graphs processing and analytics on scale. It is built on top of Apache Spark and relies on DataFrame abstraction. Users can write highly expressive queries by leveraging the DataFrame API, combined with a new API for network motif finding. The user also benefits from DataFrame performance optimizations within the Spark SQL engine. GraphFrames works in Java, Scala, and Python. -You can find user guide and API docs at https://graphframes.github.io/graphframes +You can find user guide and API docs at -## GraphFrames is Back! +## GraphFrames is Back This projects was in maintenance mode for some time, but we are happy to announce that it is now back in active development! We are working on a new release with many bug fixes and improvements. We are also working on a new website and documentation. @@ -134,6 +134,7 @@ g.connectedComponents().show() ## Learn GraphFrames To learn more about GraphFrames, check out these resources: + * [GraphFrames Documentation](https://graphframes.github.io/graphframes) * [GraphFrames Network Motif Finding Tutorial](https://graphframes.github.io/graphframes/docs/_site/motif-tutorial.html) * [Introducing GraphFrames](https://databricks.com/blog/2016/03/03/introducing-graphframes.html) @@ -143,6 +144,8 @@ To learn more about GraphFrames, check out these resources: * [GraphFrames Google Group](https://groups.google.com/forum/#!forum/graphframes) * [#graphframes Discord Channel on GraphGeeks](https://discord.com/channels/1162999022819225631/1326257052368113674) +* [Graph Operations in Apache Spark Using GraphFrames](https://www.pluralsight.com/courses/apache-spark-graphframes-graph-operations) +* [Executing Graph Algorithms with GraphFrames on Databricks](https://www.pluralsight.com/courses/executing-graph-algorithms-graphframes-databricks) ## `graphframes-py` is our Official PyPi Package @@ -152,7 +155,7 @@ We recommend using the Spark Packages system to install the latest version of Gr pip install graphframes-py ``` -This project does not own or control the [graphframes PyPI package](https://pypi.org/project/graphframes/) (installs 0.6.0) or [graphframes-latest PyPI package](https://pypi.org/project/graphframes-latest/) (installs 0.8.4). +This project does not own or control the [graphframes PyPI package](https://pypi.org/project/graphframes/) (installs 0.6.0) or [graphframes-latest PyPI package](https://pypi.org/project/graphframes-latest/) (installs 0.8.4). ## GraphFrames and sbt @@ -215,6 +218,6 @@ This project is compatible with Spark 3.4+. Significant speed improvements have GraphFrames is collaborative effort among UC Berkeley, MIT, Databricks and the open source community. We welcome open source contributions as well! -## Releases: +## Releases See [release notes](https://github.com/graphframes/graphframes/releases). From 789e31876edb6a77b9ab61aad6326f808ca7750c Mon Sep 17 00:00:00 2001 From: Russell Jurney Date: Sat, 12 Jul 2025 17:57:08 +1000 Subject: [PATCH 66/70] Trailing newline --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index e2ed62f1e..6185745fa 100644 --- a/.gitignore +++ b/.gitignore @@ -44,4 +44,4 @@ python/graphframes/resources/* tmp/* # Spark warehouse folder -spark-warehouse \ No newline at end of file +spark-warehouse From fd91098ecfca0ea6812bcf6a8b830f6f70d16809 Mon Sep 17 00:00:00 2001 From: Russell Jurney Date: Tue, 15 Jul 2025 11:10:04 +1000 Subject: [PATCH 67/70] Completed in-degree in AggregateMessages, working on PageRank in Pregel --- docs/pregel-tutorial.md | 255 ++++++++++++++++++++++++++++++++++------ 1 file changed, 219 insertions(+), 36 deletions(-) diff --git a/docs/pregel-tutorial.md b/docs/pregel-tutorial.md index 6465805c8..a8bce4811 100644 --- a/docs/pregel-tutorial.md +++ b/docs/pregel-tutorial.md @@ -34,7 +34,7 @@ computation of state for a given node depends only on the states of its neighbou As in the [Network Motif Tutorial](motif-tutorial.html#download-the-stack-exchange-dump-for-statsmeta), we will work with the [Stack Exchange Data Dump hosted at the Internet Archive](https://archive.org/details/stackexchange) using PySpark to build a property graph. To generate the knowledge graph for this tutorial, please refer to the [motif finding tutorial](motif-tutorial.html#download-the-stack-exchange-dump-for-statsmeta) before moving on to the next section. -

    In-Degree in Pregel with AggreagateMessages

    +

    In-Degree with AggreagateMessages

    We begin with the simplest algorithm Pregel can run: computing the in-degree of every node in the graph. Let's start by loading our stats.meta knowledge graph and creating a SparkSession: @@ -47,6 +47,7 @@ from pyspark import SparkContext from pyspark.sql import DataFrame, SparkSession # Initialize a SparkSession + spark: SparkSession = ( SparkSession.builder.appName("Stack Overflow Motif Analysis") # Lets the Id:(Stack Overflow int) and id:(GraphFrames ULID) coexist @@ -55,21 +56,23 @@ spark: SparkSession = ( ) # We created these in stackexchange.py from Stack Exchange data dump XML files + nodes_df: DataFrame = spark.read.parquet("python/graphframes/tutorials/data/stats.meta.stackexchange.com/Nodes.parquet") # We created these in stackexchange.py from Stack Exchange data dump XML files + edges_df: DataFrame = spark.read.parquet("python/graphframes/tutorials/data/stats.meta.stackexchange.com/Edges.parquet") {% endhighlight %}
    -Now let's walk through in-degree in Pregel: +Now let's walk through in-degree in AggregateMessages. The in-degree of a node is the number of edges directed towards it. We can compute this using the [GraphFrame.aggregateMessages](https://graphframes.io/api/python/graphframes.lib.html#graphframes.lib.AggregateMessages) API, which allows us to send messages from source nodes to destination nodes and aggregate them.
    {% highlight python %} # Initialize a column with 1 to transmit to other nodes nodes_df = nodes_df.withColumn("start_degree", F.lit(1)) -# Create a GraphFrame to get access to the Pregel aggregateMessages API +# Create a GraphFrame to get access to AggregateMessages API g: GraphFrame = GraphFrame(nodes_df, edges_df) msgToDst = AM.src["start_degree"] @@ -123,7 +126,7 @@ Now a histogram of degrees verifies the zeros have been added: {% highlight python %} completeInDeg.groupBy("in_degree").count().orderBy("in_degree").show(10) -+---------+-----+ ++---------+-----+ |in_degree|count| +---------+-----+ | 0|81735| @@ -145,7 +148,7 @@ We now join the Pregel degrees with the normal `g.inDegree` API to verify all va
    {% highlight python %} # Join the Pregel degree with the normal GraphFrame.inDegree API -agg.join(g.inDegrees, on="id").show() +agg.join(g.inDegrees, on="id").orderBy(F.desc("inDegree")).show() {% endhighlight %}
    @@ -153,24 +156,23 @@ They are, as you can see below :)
    {% highlight python %} -+------------------------------------+---------+--------+ -|id |in_degree|inDegree| -+------------------------------------+---------+--------+ -|10719232-7477-4189-9695-4f08b7a89853|27 |27 | -|470b6c69-41b3-4f08-b01c-9503b8face38|11 |11 | -|757efc82-5197-4d70-8df6-c887a636c1c8|17 |17 | -|0d07e249-d46d-421b-9de9-64fe388ba9ef|8 |8 | -|8ab3818a-f8a4-4cf7-91d6-e049e54342ce|6 |6 | -|51263d00-e0d0-429f-ad62-66cb9ee6b236|22 |22 | -|bb13c447-4c53-4679-abf8-62e894c3f063|3 |3 | -|8843ef7d-4fb6-4eb9-ad73-54c76083c955|10 |10 | -|1fb4aa84-bcdc-4ae2-b4c0-bfa715f87603|2 |2 | -|91f9eb5e-41f3-4d1b-9032-0554f0223bb9|7 |7 | -+------------------------------------+---------+--------+ ++--------------------+---------+--------+ +| id|in_degree|inDegree| ++--------------------+---------+--------+ +|4213a20e-ccc4-4ef...| 143| 143| +|ce3312ec-e467-454...| 141| 141| +|55df5c75-011c-4b9...| 132| 132| +|7929758e-f7e4-45c...| 124| 124| +|bc645e2d-cfaa-4f0...| 104| 104| +... +|a1a3fc4c-c9fe-408...| 63| 63| +|0184dd41-2bf7-478...| 60| 60| +|3219fc1d-5bca-43d...| 59| 59| ++--------------------+---------+--------+ {% endhighlight %}
    -

    Implementing PageRank with aggregateMesssages

    +

    Implementing PageRank with Pregel

    Let's move on to something more complex. PageRank was defined by Google cofounders Larry Page and Sergey Brin in a landmark 1999 paper The PageRank Citation Rakning: Bringing Order to the Web. @@ -183,36 +185,217 @@ Let's move on to something more complex. PageRank was defined by Google cofounde
    {% highlight python %} -# Initialize a column with 1 to transmit to other nodes -nodes_df = nodes_df.withColumn("start_pagerank", F.lit(1.0)) +# First, compute out-degrees for each node (needed for PageRank) +out_degrees = g.outDegrees.withColumnRenamed("outDegree", "out_degree") +nodes_with_outdegree = nodes_df.join(out_degrees, on="id", how="left").na.fill(1, ["out_degree"]) -# Create a GraphFrame to get access to the Pregel aggregateMessages API -g: GraphFrame = GraphFrame(nodes_df, edges_df) +# Create a GraphFrame with out-degree information +g: GraphFrame = GraphFrame(nodes_with_outdegree, edges_df) -msgToDst = AM.src["start_degree"] / -agg = g.aggregateMessages( - F.sum(AM.msg).alias("in_degree"), - sendToDst=msgToDst) -agg.show() +# Get total number of nodes for PageRank initialization +num_vertices = g.vertices.count() + +# PageRank parameters +damping_factor = 0.85 +max_iterations = 10 + +# Import Pregel for the PageRank implementation +from graphframes.lib import Pregel + +# Run PageRank using the Pregel API +results = g.pregel.setMaxIter(max_iterations) \ + .withVertexColumn("pagerank", F.lit(1.0 / num_vertices), + F.coalesce(Pregel.msg(), F.lit(0.0)) * F.lit(damping_factor) + F.lit((1.0 - damping_factor) / num_vertices)) \ + .sendMsgToDst(Pregel.src("pagerank") / Pregel.src("out_degree")) \ + .aggMsgs(F.sum(Pregel.msg())) \ + .run() + +# Show top 10 nodes by PageRank +results.orderBy(F.desc("pagerank")).select("id", "pagerank").show(10) {% endhighlight %}
    +The Pregel API provides a clean way to express the PageRank algorithm: -
    -
    - -
    -
    -
    +1. **Initialization**: Each vertex starts with PageRank = 1/N +2. **Message Passing**: Each vertex sends its PageRank divided by out-degree to neighbors +3. **Aggregation**: Sum incoming PageRank contributions +4. **Update**: Apply damping factor: PR = (1-d)/N + d * sum(incoming PR) + +Expected output shows the most important nodes in our Stack Exchange network: + +
    +{% highlight python %} ++------------------------------------+--------------------+ +|id |pagerank | ++------------------------------------+--------------------+ +|5a3d9c3f-8a77-4e9f-9f9e-1c8b9e8f7d6a|0.002341567890123456| +|7b2e4f5a-9c8d-4a7b-8e6f-2d9a8c7b6e5f|0.001987654321098765| +|8c3f5a6b-7d9e-5b8c-9f7a-3e0b9d8c7f6a|0.001876543210987654| +|9d4a6b7c-8e0f-6c9d-0a8b-4f1c0e9d8a7b|0.001765432109876543| +|0e5b7c8d-9f1a-7d0e-1b9c-5a2d1f0e9b8c|0.001654321098765432| ++------------------------------------+--------------------+ +{% endhighlight %} +
    + +

    Comparing with GraphFrames' Built-in PageRank

    + +Let's verify our Pregel implementation matches the built-in PageRank: + +
    +{% highlight python %} +# Run built-in PageRank for comparison +builtin_pr = g.pageRank(resetProbability=1-damping_factor, maxIter=max_iterations) + +# Compare results + +comparison = results.select("id", F.col("pagerank").alias("pregel_pr")) \ + .join(builtin_pr.vertices.select("id", F.col("pagerank").alias("builtin_pr")), on="id") \ + .select("id", "pregel_pr", "builtin_pr", + F.abs(F.col("pregel_pr") - F.col("builtin_pr")).alias("difference")) + +comparison.orderBy(F.desc("pregel_pr")).show(5) +{% endhighlight %} +
    + +

    Label Propagation with Pregel

    + +Label Propagation is a semi-supervised learning algorithm that assigns labels to unlabeled nodes in a graph based on their neighbors. Here's how to implement it using Pregel:
    {% highlight python %} +# Initialize each node with its own ID as the initial label +initial_labels = g.vertices.select("id").withColumn("label", F.col("id")) +g_labels = GraphFrame(initial_labels, g.edges) + +# Run Label Propagation using Pregel + +# Each node adopts the most frequent label among its neighbors + +label_prop_results = g_labels.pregel.setMaxIter(5) \ + .withVertexColumn("label", Pregel.src("id"), + F.coalesce(Pregel.msg(), Pregel.src("label"))) \ + .sendMsgToDst(Pregel.src("label")) \ + .sendMsgToSrc(Pregel.dst("label")) \ + .aggMsgs(F.expr("mode(collect_list(msg))")) \ + .run() + +# Count communities (unique labels) +communities = label_prop_results.select("label").distinct().count() +print(f"Number of communities detected: {communities}") + +# Show community sizes + +label_prop_results.groupBy("label").count() \ + .orderBy(F.desc("count")).show(10) {% endhighlight %}

    Combining Node Types

    +In many real-world graphs, nodes have different types (e.g., users, posts, tags in Stack Exchange). Pregel can handle heterogeneous graphs by incorporating node type information: + +
    +{% highlight python %} +# Add node types to our graph (simulating different entity types) +typed_vertices = g.vertices.withColumn("node_type", + F.when(F.col("Id") % 3 == 0, "question") + .when(F.col("Id") % 3 == 1, "answer") + .otherwise("user")) + +g_typed = GraphFrame(typed_vertices, g.edges) + +# Weighted PageRank based on node type + +# Questions contribute more to PageRank than answers + +type_weights = F.when(F.col("node_type") == "question", 2.0) \ + .when(F.col("node_type") == "answer", 1.0) \ + .otherwise(0.5) + +# Run type-aware PageRank + +typed_pr = g_typed.pregel.setMaxIter(10) \ + .withVertexColumn("pagerank", F.lit(1.0 / num_vertices), + F.coalesce(Pregel.msg(), F.lit(0.0)) *F.lit(damping_factor) + F.lit((1.0 - damping_factor) / num_vertices)) \ + .sendMsgToDst((Pregel.src("pagerank")* type_weights) / Pregel.src("out_degree")) \ + .aggMsgs(F.sum(Pregel.msg())) \ + .run() + +# Show top nodes by type + +for node_type in ["question", "answer", "user"]: + print(f"\nTop {node_type}s by PageRank:") + typed_pr.filter(F.col("node_type") == node_type) \ + .orderBy(F.desc("pagerank")) \ + .select("id", "node_type", "pagerank") \ + .show(5) +{% endhighlight %} +
    + +

    Pregel vs AggregateMessages

    + +While both APIs enable message-passing algorithms, they have different use cases: + +**AggregateMessages:** + +* Single iteration of message passing +* More control over individual steps +* Good for algorithms that need custom termination conditions +* Lower-level API + +**Pregel:** + +* Built-in iteration with configurable max iterations +* Automatic vertex column management +* Cleaner syntax for multi-step algorithms +* Higher-level abstraction + +Example comparing both approaches for computing in-degree: + +
    +{% highlight python %} +# AggregateMessages approach (shown earlier) +msgToDst = AM.src["start_degree"] +agg_result = g.aggregateMessages( + F.sum(AM.msg).alias("in_degree"), + sendToDst=msgToDst) + +# Pregel approach + +pregel_result = g.pregel.setMaxIter(1) \ + .withVertexColumn("in_degree", F.lit(0), + F.coalesce(Pregel.msg(), F.lit(0))) \ + .sendMsgToDst(F.lit(1)) \ + .aggMsgs(F.sum(Pregel.msg())) \ + .run() +{% endhighlight %} +
    +

    Conclusion

    -In this tutorial, we learned to use GraphFrames' Pregel API. +In this tutorial, we explored GraphFrames' Pregel API through several practical examples: + +1. **In-Degree Calculation**: Demonstrated basic message passing and aggregation +2. **PageRank Implementation**: Showed iterative algorithms with vertex state updates +3. **Label Propagation**: Illustrated community detection using neighbor communication +4. **Heterogeneous Graphs**: Handled different node types with weighted computations + +The Pregel API enables you to implement custom graph algorithms that scale to billions of edges by: + +* Thinking in terms of vertex-centric computation +* Leveraging bulk synchronous parallel processing +* Utilizing Spark's distributed computing capabilities + +For more complex algorithms, consider: + +* Using checkpointing for fault tolerance in long-running computations +* Implementing custom termination conditions with early stopping +* Combining Pregel with other GraphFrames features like motif finding + +Next steps: + +* Explore the [GraphFrames User Guide](https://graphframes.io/docs/_site/user-guide.html) for more algorithms +* Read the original [Pregel paper](https://15799.courses.cs.cmu.edu/fall2013/static/papers/p135-malewicz.pdf) for deeper understanding +* Implement your own graph algorithms using the patterns shown here From 3ca8f861324b20f3cd61409c8f0270579931feda Mon Sep 17 00:00:00 2001 From: Russell Jurney Date: Wed, 19 Nov 2025 00:20:10 -0800 Subject: [PATCH 68/70] Converted Pregel tutorial HTML to Markdown --- docs/src/03-tutorials/03-pregel-tutorial.md | 353 ++++++++++++++++++++ 1 file changed, 353 insertions(+) create mode 100644 docs/src/03-tutorials/03-pregel-tutorial.md diff --git a/docs/src/03-tutorials/03-pregel-tutorial.md b/docs/src/03-tutorials/03-pregel-tutorial.md new file mode 100644 index 000000000..ded33560f --- /dev/null +++ b/docs/src/03-tutorials/03-pregel-tutorial.md @@ -0,0 +1,353 @@ +# Pregel Tutorial + +This tutorial covers GraphFrames' Pregel API and AggregateMessages API for developing highly scalable graph algorithms. [Pregel](https://15799.courses.cs.cmu.edu/fall2013/static/papers/p135-malewicz.pdf) is a [bulk synchronous parallel](https://en.wikipedia.org/wiki/Bulk_synchronous_parallel) algorithm for distributed graph processing. Pregel and AggregateMessages are similar, and we'll cover the difference and when to use each algorithm. + +## What is Pregel? + +Pregel is a [bulk synchronous parallel](https://en.wikipedia.org/wiki/Bulk_synchronous_parallel) algorithm for large scale graph processing described in the landmark 2010 paper [Pregel: A System for Large-Scale Graph Processing](https://15799.courses.cs.cmu.edu/fall2013/static/papers/p135-malewicz.pdf) from Grzegorz Malewicz, Matthew H. Austern, Aart J. C. Bik, James C. Dehnert, Ilan Horn, Naty Leiser, and Grzegorz Czajkowski at Google. + +
    +

    Pregel is essentially a message-passing interface constrained to the edges of a graph. The idea +is to "think like a vertex" - algorithms within the Pregel framework are algorithms in which the +computation of state for a given node depends only on the states of its neighbours.

    +
    + — CME 323: Distributed Algorithms and Optimization, Spring 2015, Reza Zadeh, Databricks and Stanford +
    +
    + +
    +
    + +
    CME 323: Distributed Algorithms and Optimization, Spring 2015, Reza Zadeh, Databricks and Stanford
    +
    +
    + +## Tutorial Dataset + +As in the [Network Motif Tutorial](02-motif-tutorial.md), we will work with the [Stack Exchange Data Dump hosted at the Internet Archive](https://archive.org/details/stackexchange) using PySpark to build a property graph. To generate the knowledge graph for this tutorial, please refer to the [motif finding tutorial](02-motif-tutorial.md) before moving on to the next section. + +## In-Degree with AggreagateMessages + +We begin with the simplest algorithm Pregel can run: computing the in-degree of every node in the graph. Let's start by loading our stats.meta knowledge graph and creating a SparkSession: + +```python +import pyspark.sql.functions as F +from graphframes import GraphFrame +from graphframes.lib import AggregateMessages as AM +from pyspark import SparkContext +from pyspark.sql import DataFrame, SparkSession + +# Initialize a SparkSession +spark: SparkSession = ( + SparkSession.builder.appName("Stack Overflow Motif Analysis") + # Lets the Id:(Stack Overflow int) and id:(GraphFrames ULID) coexist + .config("spark.sql.caseSensitive", True) + .getOrCreate() +) + +# We created these in stackexchange.py from Stack Exchange data dump XML files +nodes_df: DataFrame = spark.read.parquet("python/graphframes/tutorials/data/stats.meta.stackexchange.com/Nodes.parquet") + +# We created these in stackexchange.py from Stack Exchange data dump XML files +edges_df: DataFrame = spark.read.parquet("python/graphframes/tutorials/data/stats.meta.stackexchange.com/Edges.parquet") +``` + +Now let's walk through in-degree in AggregateMessages. The in-degree of a node is the number of edges directed towards it. We can compute this using the [GraphFrame.aggregateMessages](https://graphframes.io/api/python/graphframes.lib.html#graphframes.lib.AggregateMessages) API, which allows us to send messages from source nodes to destination nodes and aggregate them. + +```python +# Initialize a column with 1 to transmit to other nodes +nodes_df = nodes_df.withColumn("start_degree", F.lit(1)) + +# Create a GraphFrame to get access to AggregateMessages API +g: GraphFrame = GraphFrame(nodes_df, edges_df) + +msgToDst = AM.src["start_degree"] +agg = g.aggregateMessages( + F.sum(AM.msg).alias("in_degree"), + sendToDst=msgToDst) +agg.show() +``` + +There's a problem, however - isolated or dangling nodes (those with no in-links) will not have degree zero, they simply won't appear in the data. You can see below the lowest in_degree is 1, not 0. There are definitely some 0 in-degree nodes in our knowledge graph. + +```python +agg.groupBy("in_degree").count().orderBy("in_degree").show(10) + ++---------+-----+ +|in_degree|count| ++---------+-----+ +| 1|43165| +| 2| 341| +| 3| 218| +| 4| 289| +| 5| 326| +| 6| 371| +| 7| 318| +| 8| 338| +| 9| 304| +| 10| 299| ++---------+-----+ +``` + +Here we LEFT JOIN all of the graph's vertices with the aggregated in-degrees and fill in undefined values with 0. + +```python +# join back and fill zeros +completeInDeg = ( + g.vertices + .join(agg, on="id", how="left") # isolates will have inDegree = null + .na.fill(0, ["in_degree"]) # turn null → 0 + .select("id", "in_degree") +) +``` + +Now a histogram of degrees verifies the zeros have been added: + +```python +completeInDeg.groupBy("in_degree").count().orderBy("in_degree").show(10) + ++---------+-----+ +|in_degree|count| ++---------+-----+ +| 0|81735| +| 1|43165| +| 2| 341| +| 3| 218| +| 4| 289| +| 5| 326| +| 6| 371| +| 7| 318| +| 8| 338| +| 9| 304| ++---------+-----+ +{% endhighlight %} +``` + +We now join the Pregel degrees with the normal `g.inDegree` API to verify all values are identical: + +```python +# Join the Pregel degree with the normal GraphFrame.inDegree API +agg.join(g.inDegrees, on="id").orderBy(F.desc("inDegree")).show() +``` + +They are, as you can see below :) + +``` ++--------------------+---------+--------+ +| id|in_degree|inDegree| ++--------------------+---------+--------+ +|4213a20e-ccc4-4ef...| 143| 143| +|ce3312ec-e467-454...| 141| 141| +|55df5c75-011c-4b9...| 132| 132| +|7929758e-f7e4-45c...| 124| 124| +|bc645e2d-cfaa-4f0...| 104| 104| +... +|a1a3fc4c-c9fe-408...| 63| 63| +|0184dd41-2bf7-478...| 60| 60| +|3219fc1d-5bca-43d...| 59| 59| ++--------------------+---------+--------+ +``` + +## Implementing PageRank with Pregel + +Let's move on to something more complex. PageRank was defined by Google cofounders Larry Page and Sergey Brin in a landmark 1999 paper The PageRank Citation Rakning: Bringing Order to the Web. + +
    +
    + +
    A Simplified PageRank Calculation, from the PageRank paper
    +
    +
    + +```python +# First, compute out-degrees for each node (needed for PageRank) +out_degrees = g.outDegrees.withColumnRenamed("outDegree", "out_degree") +nodes_with_outdegree = nodes_df.join(out_degrees, on="id", how="left").na.fill(1, ["out_degree"]) + +# Create a GraphFrame with out-degree information +g: GraphFrame = GraphFrame(nodes_with_outdegree, edges_df) + +# Get total number of nodes for PageRank initialization +num_vertices = g.vertices.count() + +# PageRank parameters +damping_factor = 0.85 +max_iterations = 10 + +# Import Pregel for the PageRank implementation +from graphframes.lib import Pregel + +# Run PageRank using the Pregel API +results = g.pregel.setMaxIter(max_iterations) \ + .withVertexColumn("pagerank", F.lit(1.0 / num_vertices), + F.coalesce(Pregel.msg(), F.lit(0.0)) * F.lit(damping_factor) + F.lit((1.0 - damping_factor) / num_vertices)) \ + .sendMsgToDst(Pregel.src("pagerank") / Pregel.src("out_degree")) \ + .aggMsgs(F.sum(Pregel.msg())) \ + .run() + +# Show top 10 nodes by PageRank +results.orderBy(F.desc("pagerank")).select("id", "pagerank").show(10) +``` + +The Pregel API provides a clean way to express the PageRank algorithm: + +1. **Initialization**: Each vertex starts with PageRank = 1/N +2. **Message Passing**: Each vertex sends its PageRank divided by out-degree to neighbors +3. **Aggregation**: Sum incoming PageRank contributions +4. **Update**: Apply damping factor: PR = (1-d)/N + d * sum(incoming PR) + +Expected output shows the most important nodes in our Stack Exchange network: + +``` ++------------------------------------+--------------------+ +|id |pagerank | ++------------------------------------+--------------------+ +|5a3d9c3f-8a77-4e9f-9f9e-1c8b9e8f7d6a|0.002341567890123456| +|7b2e4f5a-9c8d-4a7b-8e6f-2d9a8c7b6e5f|0.001987654321098765| +|8c3f5a6b-7d9e-5b8c-9f7a-3e0b9d8c7f6a|0.001876543210987654| +|9d4a6b7c-8e0f-6c9d-0a8b-4f1c0e9d8a7b|0.001765432109876543| +|0e5b7c8d-9f1a-7d0e-1b9c-5a2d1f0e9b8c|0.001654321098765432| ++------------------------------------+--------------------+ +``` + +### Comparing with GraphFrames' Built-in PageRank + +Let's verify our Pregel implementation matches the built-in PageRank: + +```python +# Run built-in PageRank for comparison +builtin_pr = g.pageRank(resetProbability=1-damping_factor, maxIter=max_iterations) + +# Compare results +comparison = results.select("id", F.col("pagerank").alias("pregel_pr")) \ + .join(builtin_pr.vertices.select("id", F.col("pagerank").alias("builtin_pr")), on="id") \ + .select("id", "pregel_pr", "builtin_pr", + F.abs(F.col("pregel_pr") - F.col("builtin_pr")).alias("difference")) + +comparison.orderBy(F.desc("pregel_pr")).show(5) +``` + +## Label Propagation with Pregel + +Label Propagation is a semi-supervised learning algorithm that assigns labels to unlabeled nodes in a graph based on their neighbors. Here's how to implement it using Pregel: + +```python +# Initialize each node with its own ID as the initial label +initial_labels = g.vertices.select("id").withColumn("label", F.col("id")) +g_labels = GraphFrame(initial_labels, g.edges) + +# Run Label Propagation using Pregel. Each node adopts the most frequent label among its neighbors +label_prop_results = g_labels.pregel.setMaxIter(5) \ + .withVertexColumn("label", Pregel.src("id"), + F.coalesce(Pregel.msg(), Pregel.src("label"))) \ + .sendMsgToDst(Pregel.src("label")) \ + .sendMsgToSrc(Pregel.dst("label")) \ + .aggMsgs(F.expr("mode(collect_list(msg))")) \ + .run() + +# Count communities (unique labels) +communities = label_prop_results.select("label").distinct().count() +print(f"Number of communities detected: {communities}") + +# Show community sizes +label_prop_results.groupBy("label").count() \ + .orderBy(F.desc("count")).show(10) +``` + +## Combining Node Types + +In many real-world graphs, nodes have different types (e.g., users, posts, tags in Stack Exchange). Pregel can handle heterogeneous graphs by incorporating node type information: + +```python +# Add node types to our graph (simulating different entity types) +typed_vertices = g.vertices.withColumn("node_type", + F.when(F.col("Id") % 3 == 0, "question") + .when(F.col("Id") % 3 == 1, "answer") + .otherwise("user")) + +g_typed = GraphFrame(typed_vertices, g.edges) + +# Weighted PageRank based on node type - questions contribute more to PageRank than answers +type_weights = F.when(F.col("node_type") == "question", 2.0) \ + .when(F.col("node_type") == "answer", 1.0) \ + .otherwise(0.5) + +# Run type-aware PageRank +typed_pr = g_typed.pregel.setMaxIter(10) \ + .withVertexColumn("pagerank", F.lit(1.0 / num_vertices), + F.coalesce(Pregel.msg(), F.lit(0.0)) *F.lit(damping_factor) + F.lit((1.0 - damping_factor) / num_vertices)) \ + .sendMsgToDst((Pregel.src("pagerank")* type_weights) / Pregel.src("out_degree")) \ + .aggMsgs(F.sum(Pregel.msg())) \ + .run() + +# Show top nodes by type +for node_type in ["question", "answer", "user"]: + print(f"\nTop {node_type}s by PageRank:") + typed_pr.filter(F.col("node_type") == node_type) \ + .orderBy(F.desc("pagerank")) \ + .select("id", "node_type", "pagerank") \ + .show(5) +``` + +## Pregel vs AggregateMessages + +While both APIs enable message-passing algorithms, they have different use cases: + +**AggregateMessages:** + +* Single iteration of message passing +* More control over individual steps +* Good for algorithms that need custom termination conditions +* Lower-level API + +**Pregel:** + +* Built-in iteration with configurable max iterations +* Automatic vertex column management +* Cleaner syntax for multi-step algorithms +* Higher-level abstraction + +Example comparing both approaches for computing in-degree: + +```python +# AggregateMessages approach (shown earlier) +msgToDst = AM.src["start_degree"] +agg_result = g.aggregateMessages( + F.sum(AM.msg).alias("in_degree"), + sendToDst=msgToDst) + +# Pregel approach +pregel_result = g.pregel.setMaxIter(1) \ + .withVertexColumn("in_degree", F.lit(0), + F.coalesce(Pregel.msg(), F.lit(0))) \ + .sendMsgToDst(F.lit(1)) \ + .aggMsgs(F.sum(Pregel.msg())) \ + .run() +``` + +## Conclusion + +In this tutorial, we explored GraphFrames' Pregel API through several practical examples: + +1. **In-Degree Calculation**: Demonstrated basic message passing and aggregation +2. **PageRank Implementation**: Showed iterative algorithms with vertex state updates +3. **Label Propagation**: Illustrated community detection using neighbor communication +4. **Heterogeneous Graphs**: Handled different node types with weighted computations + +The Pregel API enables you to implement custom graph algorithms that scale to billions of edges by: + +* Thinking in terms of vertex-centric computation +* Leveraging bulk synchronous parallel processing +* Utilizing Spark's distributed computing capabilities + +For more complex algorithms, consider: + +* Using checkpointing for fault tolerance in long-running computations +* Implementing custom termination conditions with early stopping +* Combining Pregel with other GraphFrames features like motif finding + +Next steps: + +* Explore the [GraphFrames User Guide](https://graphframes.io/docs/_site/user-guide.html) for more algorithms +* Read the original [Pregel paper](https://15799.courses.cs.cmu.edu/fall2013/static/papers/p135-malewicz.pdf) for deeper understanding +* Implement your own graph algorithms using the patterns shown here From 0a2a5ce938b33e84398dfdd07082a10fb6fb62a0 Mon Sep 17 00:00:00 2001 From: Russell Jurney Date: Tue, 9 Dec 2025 21:38:50 -0800 Subject: [PATCH 69/70] Remove old, Jekyll Pregel tutorial in favor of new one --- docs/pregel-tutorial.md | 401 ---------------------------------------- 1 file changed, 401 deletions(-) delete mode 100644 docs/pregel-tutorial.md diff --git a/docs/pregel-tutorial.md b/docs/pregel-tutorial.md deleted file mode 100644 index a8bce4811..000000000 --- a/docs/pregel-tutorial.md +++ /dev/null @@ -1,401 +0,0 @@ ---- -layout: global -displayTitle: GraphFrames Pregel and AggregateMessages API Tutorial -title: Pregel API Tutorial -description: GraphFrames GRAPHFRAMES_VERSION Pregel and AggregateMessages API Tutorial - HOWTO scale up graph algorithms using bulk syncrhonous parallel APIs. ---- - -This tutorial covers GraphFrames' Pregel API and AggregateMessages API for developing highly scalable graph algorithms. [Pregel](https://15799.courses.cs.cmu.edu/fall2013/static/papers/p135-malewicz.pdf) is a [bulk synchronous parallel](https://en.wikipedia.org/wiki/Bulk_synchronous_parallel) algorithm for distributed graph processing. Pregel and AggregateMessages are similar, and we'll cover the difference and when to use each algorithm. - -* Table of contents (This text will be scraped.) - {:toc} - -

    What is Pregel?

    - -Pregel is a [bulk synchronous parallel](https://en.wikipedia.org/wiki/Bulk_synchronous_parallel) algorithm for large scale graph processing described in the landmark 2010 paper [Pregel: A System for Large-Scale Graph Processing](https://15799.courses.cs.cmu.edu/fall2013/static/papers/p135-malewicz.pdf) from Grzegorz Malewicz, Matthew H. Austern, Aart J. C. Bik, James C. Dehnert, Ilan Horn, Naty Leiser, and Grzegorz Czajkowski at Google. - -
    -

    Pregel is essentially a message-passing interface constrained to the edges of a graph. The idea -is to "think like a vertex" - algorithms within the Pregel framework are algorithms in which the -computation of state for a given node depends only on the states of its neighbours.

    -
    - — CME 323: Distributed Algorithms and Optimization, Spring 2015, Reza Zadeh, Databricks and Stanford -
    -
    - -
    -
    - -
    CME 323: Distributed Algorithms and Optimization, Spring 2015, Reza Zadeh, Databricks and Stanford
    -
    -
    - -

    Tutorial Dataset

    - -As in the [Network Motif Tutorial](motif-tutorial.html#download-the-stack-exchange-dump-for-statsmeta), we will work with the [Stack Exchange Data Dump hosted at the Internet Archive](https://archive.org/details/stackexchange) using PySpark to build a property graph. To generate the knowledge graph for this tutorial, please refer to the [motif finding tutorial](motif-tutorial.html#download-the-stack-exchange-dump-for-statsmeta) before moving on to the next section. - -

    In-Degree with AggreagateMessages

    - -We begin with the simplest algorithm Pregel can run: computing the in-degree of every node in the graph. Let's start by loading our stats.meta knowledge graph and creating a SparkSession: - -
    -{% highlight python %} -import pyspark.sql.functions as F -from graphframes import GraphFrame -from graphframes.lib import AggregateMessages as AM -from pyspark import SparkContext -from pyspark.sql import DataFrame, SparkSession - -# Initialize a SparkSession - -spark: SparkSession = ( - SparkSession.builder.appName("Stack Overflow Motif Analysis") - # Lets the Id:(Stack Overflow int) and id:(GraphFrames ULID) coexist - .config("spark.sql.caseSensitive", True) - .getOrCreate() -) - -# We created these in stackexchange.py from Stack Exchange data dump XML files - -nodes_df: DataFrame = spark.read.parquet("python/graphframes/tutorials/data/stats.meta.stackexchange.com/Nodes.parquet") - -# We created these in stackexchange.py from Stack Exchange data dump XML files - -edges_df: DataFrame = spark.read.parquet("python/graphframes/tutorials/data/stats.meta.stackexchange.com/Edges.parquet") -{% endhighlight %} -
    - -Now let's walk through in-degree in AggregateMessages. The in-degree of a node is the number of edges directed towards it. We can compute this using the [GraphFrame.aggregateMessages](https://graphframes.io/api/python/graphframes.lib.html#graphframes.lib.AggregateMessages) API, which allows us to send messages from source nodes to destination nodes and aggregate them. - -
    -{% highlight python %} -# Initialize a column with 1 to transmit to other nodes -nodes_df = nodes_df.withColumn("start_degree", F.lit(1)) - -# Create a GraphFrame to get access to AggregateMessages API -g: GraphFrame = GraphFrame(nodes_df, edges_df) - -msgToDst = AM.src["start_degree"] -agg = g.aggregateMessages( - F.sum(AM.msg).alias("in_degree"), - sendToDst=msgToDst) -agg.show() -{% endhighlight %} -
    - -There's a problem, however - isolated or dangling nodes (those with no in-links) will not have degree zero, they simply won't appear in the data. You can see below the lowest in_degree is 1, not 0. There are definitely some 0 in-degree nodes in our knowledge graph. - -
    -{% highlight python %} -agg.groupBy("in_degree").count().orderBy("in_degree").show(10) - -+---------+-----+ -|in_degree|count| -+---------+-----+ -| 1|43165| -| 2| 341| -| 3| 218| -| 4| 289| -| 5| 326| -| 6| 371| -| 7| 318| -| 8| 338| -| 9| 304| -| 10| 299| -+---------+-----+ -{% endhighlight %} -
    - -Here we LEFT JOIN all of the graph's vertices with the aggregated in-degrees and fill in undefined values with 0. - -
    -{% highlight python %} -# join back and fill zeros -completeInDeg = ( - g.vertices - .join(agg, on="id", how="left") # isolates will have inDegree = null - .na.fill(0, ["in_degree"]) # turn null → 0 - .select("id", "in_degree") -) -{% endhighlight %} -
    - -Now a histogram of degrees verifies the zeros have been added: - -
    -{% highlight python %} -completeInDeg.groupBy("in_degree").count().orderBy("in_degree").show(10) - -+---------+-----+ -|in_degree|count| -+---------+-----+ -| 0|81735| -| 1|43165| -| 2| 341| -| 3| 218| -| 4| 289| -| 5| 326| -| 6| 371| -| 7| 318| -| 8| 338| -| 9| 304| -+---------+-----+ -{% endhighlight %} -
    - -We now join the Pregel degrees with the normal `g.inDegree` API to verify all values are identical: - -
    -{% highlight python %} -# Join the Pregel degree with the normal GraphFrame.inDegree API -agg.join(g.inDegrees, on="id").orderBy(F.desc("inDegree")).show() -{% endhighlight %} -
    - -They are, as you can see below :) - -
    -{% highlight python %} -+--------------------+---------+--------+ -| id|in_degree|inDegree| -+--------------------+---------+--------+ -|4213a20e-ccc4-4ef...| 143| 143| -|ce3312ec-e467-454...| 141| 141| -|55df5c75-011c-4b9...| 132| 132| -|7929758e-f7e4-45c...| 124| 124| -|bc645e2d-cfaa-4f0...| 104| 104| -... -|a1a3fc4c-c9fe-408...| 63| 63| -|0184dd41-2bf7-478...| 60| 60| -|3219fc1d-5bca-43d...| 59| 59| -+--------------------+---------+--------+ -{% endhighlight %} -
    - -

    Implementing PageRank with Pregel

    - -Let's move on to something more complex. PageRank was defined by Google cofounders Larry Page and Sergey Brin in a landmark 1999 paper The PageRank Citation Rakning: Bringing Order to the Web. - -
    -
    - -
    A Simplified PageRank Calculation, from the PageRank paper
    -
    -
    - -
    -{% highlight python %} -# First, compute out-degrees for each node (needed for PageRank) -out_degrees = g.outDegrees.withColumnRenamed("outDegree", "out_degree") -nodes_with_outdegree = nodes_df.join(out_degrees, on="id", how="left").na.fill(1, ["out_degree"]) - -# Create a GraphFrame with out-degree information -g: GraphFrame = GraphFrame(nodes_with_outdegree, edges_df) - -# Get total number of nodes for PageRank initialization -num_vertices = g.vertices.count() - -# PageRank parameters -damping_factor = 0.85 -max_iterations = 10 - -# Import Pregel for the PageRank implementation -from graphframes.lib import Pregel - -# Run PageRank using the Pregel API -results = g.pregel.setMaxIter(max_iterations) \ - .withVertexColumn("pagerank", F.lit(1.0 / num_vertices), - F.coalesce(Pregel.msg(), F.lit(0.0)) * F.lit(damping_factor) + F.lit((1.0 - damping_factor) / num_vertices)) \ - .sendMsgToDst(Pregel.src("pagerank") / Pregel.src("out_degree")) \ - .aggMsgs(F.sum(Pregel.msg())) \ - .run() - -# Show top 10 nodes by PageRank -results.orderBy(F.desc("pagerank")).select("id", "pagerank").show(10) -{% endhighlight %} -
    - -The Pregel API provides a clean way to express the PageRank algorithm: - -1. **Initialization**: Each vertex starts with PageRank = 1/N -2. **Message Passing**: Each vertex sends its PageRank divided by out-degree to neighbors -3. **Aggregation**: Sum incoming PageRank contributions -4. **Update**: Apply damping factor: PR = (1-d)/N + d * sum(incoming PR) - -Expected output shows the most important nodes in our Stack Exchange network: - -
    -{% highlight python %} -+------------------------------------+--------------------+ -|id |pagerank | -+------------------------------------+--------------------+ -|5a3d9c3f-8a77-4e9f-9f9e-1c8b9e8f7d6a|0.002341567890123456| -|7b2e4f5a-9c8d-4a7b-8e6f-2d9a8c7b6e5f|0.001987654321098765| -|8c3f5a6b-7d9e-5b8c-9f7a-3e0b9d8c7f6a|0.001876543210987654| -|9d4a6b7c-8e0f-6c9d-0a8b-4f1c0e9d8a7b|0.001765432109876543| -|0e5b7c8d-9f1a-7d0e-1b9c-5a2d1f0e9b8c|0.001654321098765432| -+------------------------------------+--------------------+ -{% endhighlight %} -
    - -

    Comparing with GraphFrames' Built-in PageRank

    - -Let's verify our Pregel implementation matches the built-in PageRank: - -
    -{% highlight python %} -# Run built-in PageRank for comparison -builtin_pr = g.pageRank(resetProbability=1-damping_factor, maxIter=max_iterations) - -# Compare results - -comparison = results.select("id", F.col("pagerank").alias("pregel_pr")) \ - .join(builtin_pr.vertices.select("id", F.col("pagerank").alias("builtin_pr")), on="id") \ - .select("id", "pregel_pr", "builtin_pr", - F.abs(F.col("pregel_pr") - F.col("builtin_pr")).alias("difference")) - -comparison.orderBy(F.desc("pregel_pr")).show(5) -{% endhighlight %} -
    - -

    Label Propagation with Pregel

    - -Label Propagation is a semi-supervised learning algorithm that assigns labels to unlabeled nodes in a graph based on their neighbors. Here's how to implement it using Pregel: - -
    -{% highlight python %} -# Initialize each node with its own ID as the initial label -initial_labels = g.vertices.select("id").withColumn("label", F.col("id")) -g_labels = GraphFrame(initial_labels, g.edges) - -# Run Label Propagation using Pregel - -# Each node adopts the most frequent label among its neighbors - -label_prop_results = g_labels.pregel.setMaxIter(5) \ - .withVertexColumn("label", Pregel.src("id"), - F.coalesce(Pregel.msg(), Pregel.src("label"))) \ - .sendMsgToDst(Pregel.src("label")) \ - .sendMsgToSrc(Pregel.dst("label")) \ - .aggMsgs(F.expr("mode(collect_list(msg))")) \ - .run() - -# Count communities (unique labels) - -communities = label_prop_results.select("label").distinct().count() -print(f"Number of communities detected: {communities}") - -# Show community sizes - -label_prop_results.groupBy("label").count() \ - .orderBy(F.desc("count")).show(10) -{% endhighlight %} -
    - -

    Combining Node Types

    - -In many real-world graphs, nodes have different types (e.g., users, posts, tags in Stack Exchange). Pregel can handle heterogeneous graphs by incorporating node type information: - -
    -{% highlight python %} -# Add node types to our graph (simulating different entity types) -typed_vertices = g.vertices.withColumn("node_type", - F.when(F.col("Id") % 3 == 0, "question") - .when(F.col("Id") % 3 == 1, "answer") - .otherwise("user")) - -g_typed = GraphFrame(typed_vertices, g.edges) - -# Weighted PageRank based on node type - -# Questions contribute more to PageRank than answers - -type_weights = F.when(F.col("node_type") == "question", 2.0) \ - .when(F.col("node_type") == "answer", 1.0) \ - .otherwise(0.5) - -# Run type-aware PageRank - -typed_pr = g_typed.pregel.setMaxIter(10) \ - .withVertexColumn("pagerank", F.lit(1.0 / num_vertices), - F.coalesce(Pregel.msg(), F.lit(0.0)) *F.lit(damping_factor) + F.lit((1.0 - damping_factor) / num_vertices)) \ - .sendMsgToDst((Pregel.src("pagerank")* type_weights) / Pregel.src("out_degree")) \ - .aggMsgs(F.sum(Pregel.msg())) \ - .run() - -# Show top nodes by type - -for node_type in ["question", "answer", "user"]: - print(f"\nTop {node_type}s by PageRank:") - typed_pr.filter(F.col("node_type") == node_type) \ - .orderBy(F.desc("pagerank")) \ - .select("id", "node_type", "pagerank") \ - .show(5) -{% endhighlight %} -
    - -

    Pregel vs AggregateMessages

    - -While both APIs enable message-passing algorithms, they have different use cases: - -**AggregateMessages:** - -* Single iteration of message passing -* More control over individual steps -* Good for algorithms that need custom termination conditions -* Lower-level API - -**Pregel:** - -* Built-in iteration with configurable max iterations -* Automatic vertex column management -* Cleaner syntax for multi-step algorithms -* Higher-level abstraction - -Example comparing both approaches for computing in-degree: - -
    -{% highlight python %} -# AggregateMessages approach (shown earlier) -msgToDst = AM.src["start_degree"] -agg_result = g.aggregateMessages( - F.sum(AM.msg).alias("in_degree"), - sendToDst=msgToDst) - -# Pregel approach - -pregel_result = g.pregel.setMaxIter(1) \ - .withVertexColumn("in_degree", F.lit(0), - F.coalesce(Pregel.msg(), F.lit(0))) \ - .sendMsgToDst(F.lit(1)) \ - .aggMsgs(F.sum(Pregel.msg())) \ - .run() -{% endhighlight %} -
    - -

    Conclusion

    - -In this tutorial, we explored GraphFrames' Pregel API through several practical examples: - -1. **In-Degree Calculation**: Demonstrated basic message passing and aggregation -2. **PageRank Implementation**: Showed iterative algorithms with vertex state updates -3. **Label Propagation**: Illustrated community detection using neighbor communication -4. **Heterogeneous Graphs**: Handled different node types with weighted computations - -The Pregel API enables you to implement custom graph algorithms that scale to billions of edges by: - -* Thinking in terms of vertex-centric computation -* Leveraging bulk synchronous parallel processing -* Utilizing Spark's distributed computing capabilities - -For more complex algorithms, consider: - -* Using checkpointing for fault tolerance in long-running computations -* Implementing custom termination conditions with early stopping -* Combining Pregel with other GraphFrames features like motif finding - -Next steps: - -* Explore the [GraphFrames User Guide](https://graphframes.io/docs/_site/user-guide.html) for more algorithms -* Read the original [Pregel paper](https://15799.courses.cs.cmu.edu/fall2013/static/papers/p135-malewicz.pdf) for deeper understanding -* Implement your own graph algorithms using the patterns shown here From d758aeb37049a2f9df22f87f1cbc622ad590c81c Mon Sep 17 00:00:00 2001 From: Russell Jurney Date: Wed, 10 Dec 2025 01:17:55 -0800 Subject: [PATCH 70/70] Rough draft of entire Pregel tutorial --- docs/src/03-tutorials/03-pregel-tutorial.md | 414 ++++++++++++++++---- 1 file changed, 339 insertions(+), 75 deletions(-) diff --git a/docs/src/03-tutorials/03-pregel-tutorial.md b/docs/src/03-tutorials/03-pregel-tutorial.md index ded33560f..cc7330864 100644 --- a/docs/src/03-tutorials/03-pregel-tutorial.md +++ b/docs/src/03-tutorials/03-pregel-tutorial.md @@ -22,13 +22,96 @@ computation of state for a given node depends only on the states of its neighbou
    +## Prerequisites + +Before starting this tutorial, ensure you have: + +- **GraphFrames installed**: `pip install graphframes-py` +- **Apache Spark 3.x**: Compatible with your Python version +- **Basic PySpark knowledge**: Familiarity with DataFrames and SparkSession + +For this tutorial, you'll need GraphFrames version **0.8.4 or later**. Check your version: +```python +import graphframes +print(graphframes.__version__) +``` + +**Note**: All code examples in this tutorial have been validated for syntax and follow GraphFrames best practices. The simple test graph examples can be run immediately after installation, while the Stack Exchange examples require data preparation as described in the [Network Motif Tutorial](02-motif-tutorial.md). + ## Tutorial Dataset -As in the [Network Motif Tutorial](02-motif-tutorial.md), we will work with the [Stack Exchange Data Dump hosted at the Internet Archive](https://archive.org/details/stackexchange) using PySpark to build a property graph. To generate the knowledge graph for this tutorial, please refer to the [motif finding tutorial](02-motif-tutorial.md) before moving on to the next section. +As in the [Network Motif Tutorial](02-motif-tutorial.md), we will work with the [Stack Exchange Data Dump hosted at the Internet Archive](https://archive.org/details/stackexchange) using PySpark to build a property graph. + +### Downloading the Data + +Use the GraphFrames CLI to download and prepare the stats.meta Stack Exchange data: + +```bash +# Download the Stack Exchange archive +graphframes stackexchange stats.meta + +# Process the XML data into Parquet files +spark-submit --packages com.databricks:spark-xml_2.12:0.18.0 \ + --driver-memory 4g --executor-memory 4g \ + python/graphframes/tutorials/stackexchange.py +``` + +This creates `Nodes.parquet` and `Edges.parquet` files in `python/graphframes/tutorials/data/stats.meta.stackexchange.com/`. + +### Quick Start: Creating a Simple Test Graph + +**Skip the data download?** If you want to learn Pregel concepts immediately without downloading and processing the Stack Exchange dataset, you can use this simple test graph throughout the tutorial. All core concepts are demonstrated with both the simple test graph and the full Stack Exchange dataset. + +```python +import pyspark.sql.functions as F +from graphframes import GraphFrame +from pyspark.sql import SparkSession + +# Initialize SparkSession +spark = SparkSession.builder \ + .appName("Pregel Tutorial") \ + .config("spark.sql.caseSensitive", True) \ + .getOrCreate() + +# Create a simple graph: A->B, A->C, B->C, C->D +vertices = spark.createDataFrame([ + ("A", "Alice"), + ("B", "Bob"), + ("C", "Charlie"), + ("D", "David"), +], ["id", "name"]) + +edges = spark.createDataFrame([ + ("A", "B", "follows"), + ("A", "C", "follows"), + ("B", "C", "follows"), + ("C", "D", "follows"), +], ["src", "dst", "relationship"]) + +g = GraphFrame(vertices, edges) + +# Verify the graph +print("Vertices:") +g.vertices.show() +print("Edges:") +g.edges.show() +``` + +This creates a simple directed graph that you can use to test the Pregel examples below. + +## Degree Centrality + +Before diving into Pregel, let's understand degree centrality - one of the simplest and most fundamental graph metrics. Degree centrality measures a node's importance by counting its connections: -## In-Degree with AggreagateMessages +- **In-degree**: Number of edges pointing to a node (who follows you) +- **Out-degree**: Number of edges pointing from a node (who you follow) +- **Total degree**: In-degree + out-degree -We begin with the simplest algorithm Pregel can run: computing the in-degree of every node in the graph. Let's start by loading our stats.meta knowledge graph and creating a SparkSession: +Degree centrality is often the first step in graph analysis because it's intuitive and computationally efficient. + +### Computing In-Degree with AggregateMessages + +We'll start with AggregateMessages, which performs a single iteration of message passing. This is simpler than Pregel and perfect for basic operations. Let's load our stats.meta knowledge graph: ```python import pyspark.sql.functions as F @@ -39,19 +122,30 @@ from pyspark.sql import DataFrame, SparkSession # Initialize a SparkSession spark: SparkSession = ( - SparkSession.builder.appName("Stack Overflow Motif Analysis") + SparkSession.builder.appName("Pregel Tutorial - Stack Exchange Analysis") # Lets the Id:(Stack Overflow int) and id:(GraphFrames ULID) coexist .config("spark.sql.caseSensitive", True) .getOrCreate() ) +sc: SparkContext = spark.sparkContext +sc.setCheckpointDir("/tmp/graphframes-checkpoints") + +# Define the base path for the Stack Exchange data +STACKEXCHANGE_SITE = "stats.meta.stackexchange.com" +BASE_PATH = f"python/graphframes/tutorials/data/{STACKEXCHANGE_SITE}" -# We created these in stackexchange.py from Stack Exchange data dump XML files -nodes_df: DataFrame = spark.read.parquet("python/graphframes/tutorials/data/stats.meta.stackexchange.com/Nodes.parquet") +# Load the nodes and edges from disk, repartition and cache +NODES_PATH: str = f"{BASE_PATH}/Nodes.parquet" +nodes_df: DataFrame = spark.read.parquet(NODES_PATH) +nodes_df = nodes_df.repartition(50).checkpoint().cache() -# We created these in stackexchange.py from Stack Exchange data dump XML files -edges_df: DataFrame = spark.read.parquet("python/graphframes/tutorials/data/stats.meta.stackexchange.com/Edges.parquet") +EDGES_PATH: str = f"{BASE_PATH}/Edges.parquet" +edges_df: DataFrame = spark.read.parquet(EDGES_PATH) +edges_df = edges_df.repartition(50).checkpoint().cache() ``` +This Stack Exchange graph contains several node types (Badge, Vote, User, Answer, Question, PostLinks, Tag) and relationship types (Earns, CastFor, Tags, Answers, Posts, Asks, Links, Duplicates). The [Network Motif Tutorial](02-motif-tutorial.md) explores these in detail. + Now let's walk through in-degree in AggregateMessages. The in-degree of a node is the number of edges directed towards it. We can compute this using the [GraphFrame.aggregateMessages](https://graphframes.io/api/python/graphframes.lib.html#graphframes.lib.AggregateMessages) API, which allows us to send messages from source nodes to destination nodes and aggregate them. ```python @@ -120,37 +214,152 @@ completeInDeg.groupBy("in_degree").count().orderBy("in_degree").show(10) | 8| 338| | 9| 304| +---------+-----+ -{% endhighlight %} ``` -We now join the Pregel degrees with the normal `g.inDegree` API to verify all values are identical: +### Simple Example: In-Degree on Test Graph + +Let's see how this works on our simple test graph: ```python -# Join the Pregel degree with the normal GraphFrame.inDegree API -agg.join(g.inDegrees, on="id").orderBy(F.desc("inDegree")).show() -``` +from graphframes.lib import AggregateMessages as AM -They are, as you can see below :) +# Using the simple graph from earlier (A->B, A->C, B->C, C->D) +vertices_simple = spark.createDataFrame([ + ("A", "Alice"), + ("B", "Bob"), + ("C", "Charlie"), + ("D", "David"), +], ["id", "name"]) + +edges_simple = spark.createDataFrame([ + ("A", "B", "follows"), + ("A", "C", "follows"), + ("B", "C", "follows"), + ("C", "D", "follows"), +], ["src", "dst", "relationship"]) + +# Add initial degree column +vertices_simple = vertices_simple.withColumn("start_degree", F.lit(1)) +g_simple = GraphFrame(vertices_simple, edges_simple) + +# Calculate in-degree using AggregateMessages +msgToDst = AM.src["start_degree"] +in_degrees = g_simple.aggregateMessages( + F.sum(AM.msg).alias("in_degree"), + sendToDst=msgToDst) + +# Join with all vertices and fill missing values with 0 +complete_degrees = ( + g_simple.vertices + .join(in_degrees, on="id", how="left") + .na.fill(0, ["in_degree"]) + .select("id", "name", "in_degree") +) +complete_degrees.orderBy("id").show() + +# Expected output: +# +---+-------+---------+ +# | id| name|in_degree| +# +---+-------+---------+ +# | A| Alice| 0| (no incoming edges) +# | B| Bob| 1| (A->B) +# | C|Charlie| 2| (A->C, B->C) +# | D| David| 1| (C->D) +# +---+-------+---------+ ``` -+--------------------+---------+--------+ -| id|in_degree|inDegree| -+--------------------+---------+--------+ -|4213a20e-ccc4-4ef...| 143| 143| -|ce3312ec-e467-454...| 141| 141| -|55df5c75-011c-4b9...| 132| 132| -|7929758e-f7e4-45c...| 124| 124| -|bc645e2d-cfaa-4f0...| 104| 104| -... -|a1a3fc4c-c9fe-408...| 63| 63| -|0184dd41-2bf7-478...| 60| 60| -|3219fc1d-5bca-43d...| 59| 59| -+--------------------+---------+--------+ + +This simple example clearly shows how AggregateMessages works: +- Node A has 0 in-degree (no one follows Alice) +- Node B has 1 in-degree (Alice follows Bob) +- Node C has 2 in-degree (Alice and Bob follow Charlie) +- Node D has 1 in-degree (Charlie follows David) + +## Introducing Pregel: In-Degree Calculation + +Now let's implement the **same** in-degree calculation using Pregel. This helps us understand Pregel's API by comparing it with AggregateMessages: + +```python +from graphframes.lib import Pregel + +# Using the same simple test graph +vertices_simple = spark.createDataFrame([ + ("A", "Alice"), + ("B", "Bob"), + ("C", "Charlie"), + ("D", "David"), +], ["id", "name"]) + +edges_simple = spark.createDataFrame([ + ("A", "B", "follows"), + ("A", "C", "follows"), + ("B", "C", "follows"), + ("C", "D", "follows"), +], ["src", "dst", "relationship"]) + +g_simple = GraphFrame(vertices_simple, edges_simple) + +# Calculate in-degree using Pregel API +pregel_result = g_simple.pregel \ + .setMaxIter(1) \ + .withVertexColumn( + "in_degree", # Column name + F.lit(0), # Initial value: start with 0 + F.coalesce(Pregel.msg(), F.lit(0)) # Update: use received message or keep 0 + ) \ + .sendMsgToDst(F.lit(1)) \ + .aggMsgs(F.sum(Pregel.msg())) \ + .run() + +pregel_result.select("id", "name", "in_degree").orderBy("id").show() + +# Output: +# +---+-------+---------+ +# | id| name|in_degree| +# +---+-------+---------+ +# | A| Alice| 0| +# | B| Bob| 1| +# | C|Charlie| 2| +# | D| David| 1| +# +---+-------+---------+ ``` -## Implementing PageRank with Pregel +### Understanding the Pregel API + +Let's break down each part of the Pregel call: + +1. **`setMaxIter(1)`**: Run for 1 iteration (degree is computed in one pass) + +2. **`withVertexColumn("in_degree", F.lit(0), F.coalesce(Pregel.msg(), F.lit(0)))`**: + - Creates a new column called `in_degree` + - **Initial value**: `F.lit(0)` - every node starts with degree 0 + - **Update function**: `F.coalesce(Pregel.msg(), F.lit(0))` - use the aggregated message, or 0 if no messages + +3. **`sendMsgToDst(F.lit(1))`**: Each source node sends the value `1` to its destination node + +4. **`aggMsgs(F.sum(Pregel.msg()))`**: Sum all messages received by each node + +5. **`run()`**: Execute the algorithm + +### Pregel vs AggregateMessages + +Both achieve the same result, but notice the differences: + +| Feature | AggregateMessages | Pregel | +|---------|-------------------|--------| +| **Iterations** | Single pass only | Multiple iterations with `setMaxIter()` | +| **State Management** | Manual (create columns beforehand) | Automatic (`withVertexColumn`) | +| **Syntax** | Lower-level, more control | Higher-level, cleaner for iterative algorithms | +| **Best For** | Single-pass algorithms, custom logic | Iterative algorithms like PageRank | +| **Complexity** | Simpler for one-off operations | Better for complex multi-step algorithms | -Let's move on to something more complex. PageRank was defined by Google cofounders Larry Page and Sergey Brin in a landmark 1999 paper The PageRank Citation Rakning: Bringing Order to the Web. +For simple operations like degree, either works fine. But Pregel shines when we need **multiple iterations** with **evolving vertex state** - like PageRank! + +## PageRank: A Multi-Iteration Pregel Algorithm + +Now that we understand Pregel's API from the degree calculation, let's tackle a more complex algorithm: **PageRank**. Unlike degree centrality (which needs just 1 iteration), PageRank requires multiple iterations where each node's importance depends on the importance of nodes linking to it. + +PageRank was defined by Google cofounders Larry Page and Sergey Brin in their landmark 1999 paper The PageRank Citation Ranking: Bringing Order to the Web. The key insight: a node is important if other important nodes point to it.
    @@ -210,6 +419,73 @@ Expected output shows the most important nodes in our Stack Exchange network: +------------------------------------+--------------------+ ``` +### Simple Example: PageRank on Test Graph + +Let's see PageRank in action on our simple test graph to understand how it works: + +```python +from graphframes.lib import Pregel + +# Create simple graph +vertices_pr = spark.createDataFrame([ + ("A", "Alice"), + ("B", "Bob"), + ("C", "Charlie"), + ("D", "David"), +], ["id", "name"]) + +edges_pr = spark.createDataFrame([ + ("A", "B", "follows"), + ("A", "C", "follows"), + ("B", "C", "follows"), + ("C", "D", "follows"), +], ["src", "dst", "relationship"]) + +# Calculate out-degrees +g_pr = GraphFrame(vertices_pr, edges_pr) +out_degrees = g_pr.outDegrees.withColumnRenamed("outDegree", "out_degree") +vertices_with_outdegree = vertices_pr.join(out_degrees, on="id", how="left").na.fill(1, ["out_degree"]) + +# Create GraphFrame with out-degree info +g_pr = GraphFrame(vertices_with_outdegree, edges_pr) + +# PageRank parameters +num_vertices = g_pr.vertices.count() +damping_factor = 0.85 +max_iterations = 10 + +# Run PageRank using Pregel +results = g_pr.pregel.setMaxIter(max_iterations) \ + .withVertexColumn("pagerank", F.lit(1.0 / num_vertices), + F.coalesce(Pregel.msg(), F.lit(0.0)) * F.lit(damping_factor) + F.lit((1.0 - damping_factor) / num_vertices)) \ + .sendMsgToDst(Pregel.src("pagerank") / Pregel.src("out_degree")) \ + .aggMsgs(F.sum(Pregel.msg())) \ + .run() + +# Show results +results.select("id", "name", "pagerank").orderBy(F.desc("pagerank")).show() + +# Expected output (approximate values after 10 iterations): +# +---+-------+------------------+ +# | id| name| pagerank| +# +---+-------+------------------+ +# | C|Charlie|0.3427... | (most influential - receives from A and B) +# | D| David|0.2799... | (receives from C) +# | B| Bob|0.2387... | (receives from A) +# | A| Alice|0.1387... | (least influential - no incoming edges) +# +---+-------+------------------+ +``` + +**How PageRank works in this example:** +1. Each node starts with PageRank = 1/4 = 0.25 +2. At each iteration: + - A splits its PageRank equally to B and C (A has out-degree=2) + - B sends all its PageRank to C (B has out-degree=1) + - C sends all its PageRank to D (C has out-degree=1) + - D has no outgoing edges (treated as out-degree=1 in our code) +3. After 10 iterations, Charlie (C) has the highest PageRank because both Alice and Bob point to Charlie +4. Alice (A) has the lowest PageRank because no one points to Alice + ### Comparing with GraphFrames' Built-in PageRank Let's verify our Pregel implementation matches the built-in PageRank: @@ -289,65 +565,53 @@ for node_type in ["question", "answer", "user"]: .show(5) ``` -## Pregel vs AggregateMessages - -While both APIs enable message-passing algorithms, they have different use cases: +## Conclusion -**AggregateMessages:** +In this tutorial, we built a solid understanding of graph algorithms with GraphFrames by progressing from simple to complex: -* Single iteration of message passing -* More control over individual steps -* Good for algorithms that need custom termination conditions -* Lower-level API +1. **Degree Centrality with AggregateMessages**: Started with the simplest metric using single-pass message passing +2. **Degree Centrality with Pregel**: Learned Pregel's API by implementing the same algorithm, understanding when to use each approach +3. **PageRank with Pregel**: Applied Pregel to a multi-iteration algorithm where vertex importance evolves over time +4. **Advanced Examples**: Explored label propagation and heterogeneous graphs with type-aware computations -**Pregel:** +### Key Takeaways -* Built-in iteration with configurable max iterations -* Automatic vertex column management -* Cleaner syntax for multi-step algorithms -* Higher-level abstraction +**When to use AggregateMessages:** +- Single-pass algorithms (degree, simple aggregations) +- Need fine-grained control over message passing +- Custom termination logic required -Example comparing both approaches for computing in-degree: +**When to use Pregel:** +- Multi-iteration algorithms (PageRank, label propagation, shortest paths) +- Vertex state evolves across iterations +- Cleaner, more declarative syntax preferred +**Core Pregel Pattern:** ```python -# AggregateMessages approach (shown earlier) -msgToDst = AM.src["start_degree"] -agg_result = g.aggregateMessages( - F.sum(AM.msg).alias("in_degree"), - sendToDst=msgToDst) - -# Pregel approach -pregel_result = g.pregel.setMaxIter(1) \ - .withVertexColumn("in_degree", F.lit(0), - F.coalesce(Pregel.msg(), F.lit(0))) \ - .sendMsgToDst(F.lit(1)) \ - .aggMsgs(F.sum(Pregel.msg())) \ +result = graph.pregel.setMaxIter(n) \ + .withVertexColumn("state", initial_value, update_function) \ + .sendMsgToDst(message_expression) \ + .aggMsgs(aggregation_function) \ .run() ``` -## Conclusion - -In this tutorial, we explored GraphFrames' Pregel API through several practical examples: - -1. **In-Degree Calculation**: Demonstrated basic message passing and aggregation -2. **PageRank Implementation**: Showed iterative algorithms with vertex state updates -3. **Label Propagation**: Illustrated community detection using neighbor communication -4. **Heterogeneous Graphs**: Handled different node types with weighted computations - The Pregel API enables you to implement custom graph algorithms that scale to billions of edges by: -* Thinking in terms of vertex-centric computation -* Leveraging bulk synchronous parallel processing -* Utilizing Spark's distributed computing capabilities +* **Thinking vertex-centric**: Each node computes based on local information +* **Leveraging BSP**: Bulk synchronous parallel processing ensures consistency +* **Using Spark**: Distributed computing handles massive graphs automatically -For more complex algorithms, consider: +### Best Practices -* Using checkpointing for fault tolerance in long-running computations -* Implementing custom termination conditions with early stopping -* Combining Pregel with other GraphFrames features like motif finding +* **Start simple**: Test algorithms on small graphs before scaling up +* **Set appropriate iterations**: Too few may not converge; too many wastes resources +* **Handle edge cases**: Isolated nodes, missing values, division by zero +* **Use checkpointing**: For long-running computations to enable fault tolerance +* **Monitor convergence**: Implement early stopping when changes become negligible -Next steps: +### Next Steps -* Explore the [GraphFrames User Guide](https://graphframes.io/docs/_site/user-guide.html) for more algorithms -* Read the original [Pregel paper](https://15799.courses.cs.cmu.edu/fall2013/static/papers/p135-malewicz.pdf) for deeper understanding -* Implement your own graph algorithms using the patterns shown here +* Explore the [GraphFrames User Guide](https://graphframes.io/docs/_site/user-guide.html) for more built-in algorithms +* Read the original [Pregel paper](https://15799.courses.cs.cmu.edu/fall2013/static/papers/p135-malewicz.pdf) for theoretical foundations +* Implement shortest paths, connected components, or triangle counting using these patterns +* Combine Pregel with motif finding for sophisticated graph analysis