pax_global_header 0000666 0000000 0000000 00000000064 14420334703 0014512 g ustar 00root root 0000000 0000000 52 comment=897e8d56487e7c1766505298a14247299ef17e50
geneagrapher-2.0.0/ 0000775 0000000 0000000 00000000000 14420334703 0014141 5 ustar 00root root 0000000 0000000 geneagrapher-2.0.0/.github/ 0000775 0000000 0000000 00000000000 14420334703 0015501 5 ustar 00root root 0000000 0000000 geneagrapher-2.0.0/.github/workflows/ 0000775 0000000 0000000 00000000000 14420334703 0017536 5 ustar 00root root 0000000 0000000 geneagrapher-2.0.0/.github/workflows/ci.yaml 0000664 0000000 0000000 00000001454 14420334703 0021021 0 ustar 00root root 0000000 0000000 name: CI
on: [push]
jobs:
checks:
name: CI
runs-on: ubuntu-latest
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
python-version: ["3.8.12", "3.9", "3.10", "3.11"]
steps:
- uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install poetry
poetry install
- name: Black
run: |
make format-check
- name: Lint
run: |
make flake8
- name: Type hint enforcement
run: |
make mypy
- name: Test
run: |
make test
geneagrapher-2.0.0/.gitignore 0000664 0000000 0000000 00000000114 14420334703 0016125 0 ustar 00root root 0000000 0000000 *.pyc
.cache
geneagrapher.egg-info
.projectile
.python-version
dist
build
geneagrapher-2.0.0/.travis.yml 0000664 0000000 0000000 00000000157 14420334703 0016255 0 ustar 00root root 0000000 0000000 language: python
python:
- "2.7"
install:
- "pip install ."
- "pip install nose"
script:
- nosetests
geneagrapher-2.0.0/CHANGELOG.md 0000664 0000000 0000000 00000011062 14420334703 0015752 0 ustar 00root root 0000000 0000000 # 2.0.0
Released 20-Apr-2023
- This release introduces major changes to how Geneagrapher works
internally. Previously, the logic to retrieve information from the
Mathematics Genealogy Project and build graphs was in
Geneagrapher. As of this release, that work is done by a service
that Geneagrapher makes requests to. That service is built using
[geneagrapher-core](https://github.com/davidalber/geneagrapher-core).
- Graph building is now more flexible. Previously, traversal from the
graph's starting nodes was either all in the advisor direction or in
the descendant direction. Traversal directions are now specified on
a per-node basis. This has led to some syntax changes in the tool's
usage, so commands that worked in Geneagrapher 1.0.0 will not work
in 2.0.0.
- This is the first version of Geneagrapher targeted only for Python 3.
- The test system was completely overhauled and modernized.
- The source code now uses type hints.
- A CI workflow has been added to automatically run code formatting,
linter, and type hint checks, as well as the test suite.
# 1.0.0
Released 07-Oct-2018
- Redesigned data-grabbing interface to allow for the introduction of
new data grabbers.
- Added a new data grabber that builds a local cache of records. This
allows subsequent geneagrapher calls to obtain cached records and
avoiding a request over the network.
- Added local test data, allowing for many tests that were previously
making network requests to simply load local data. This speeds up
running the test suite substantially.
- Substantial refactoring and internal changes, including:
- Better code coverage by the tests.
- Updated packaging to more modern standards.
- Improved test documentation and naming.
- Internal graph structure now only stores the genealogy
graph. Previously, the graph structure retained all edges from the
Math Genealogy Project information.
- Data is now extracted from Math Genealogy Project web pages using
[BeautifulSoup](http://www.crummy.com/software/BeautifulSoup/). This
means that the Geneagrapher now has an external dependency, but I
believe the advantages for testing and code readability, for this
case, outweigh the disadvantage of taking a dependency.
# 0.2.1-r2
Released 11-Aug-2011
- A test in Geneagrapher 0.2.1-r1 was broken by new information
addedto the Mathematics Genealogy Project. This release fixes the
test, but does not change the functionality from version 0.2.1-r1.
# 0.2.1-r1
Released 03-Nov-2010
- A few tests in Geneagrapher 0.2.1 have become broken since that
version was released. This release fixes those tests, but does not
change the functionality from version 0.2.1.
# 0.2.1
Released 01-Sep-2009
- Multiple advisors are now captured correctly. While this problem was
manifesting itself, ancestor trees were coming out as a branch-free
tree.
- Added a test for the multiple advisor case, which enables quicker
recognition of similar problems.
- Updated a few tests that had become broken due to updates in the
Math Genealogy Project’s database.
# 0.2-r1
Released 07-Oct-2008
- This Geneagrapher release slightly changes an installation-related
file to enable installation on machines running Python 2.6 that have
not yet installed Python setuptools.
# 0.2.0
Released 06-Oct-2008
Here are the most significant changes, from the perspective of the
user:
- Descendant trees. Now trees can be built placing a starting node at
the top and graphing all of its descendants. A couple points on
this:
- These sorts of graphs tend to have a lot of “fan out” because some
people have a lot of students.
- Be careful. Do not inadvertently (or intentionally!) run a job that
requests the data for thousands of nodes.
- Better character handling. I believe all characters are now
displayed correctly, as long as the generated dot file is processed
by Graphviz a certain way (see the Geneagrapher 0.2 Usage Guide).
- No limit on the number of starting nodes.
- This is a client application, meaning the user installs it somewhere
and runs it there. Furthermore, this package only generates the
input file to Graphviz, so that also needs to be installed. This is
probably more of a hassle than most Geneagrapher users want to go
through (not all, though), but this is just the first step.
Additionally, behind-the-scenes changes happened:
- Large portions of the code were rewritten.
- Added a test suite to make it more maintainable. In particular, this
should allow quicker diagnosis and modifications when the
Mathematics Genealogy Project pages have changed.
geneagrapher-2.0.0/LICENSE.md 0000664 0000000 0000000 00000002063 14420334703 0015546 0 ustar 00root root 0000000 0000000 MIT License
Copyright (c) 2008–2023 David Alber
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
geneagrapher-2.0.0/Makefile 0000664 0000000 0000000 00000002206 14420334703 0015601 0 ustar 00root root 0000000 0000000 .PHONY: format flake8 mypy test
check: format-check flake8 mypy test
# Code formatting
format_targets := geneagrapher tests
format:
poetry run black $(format_targets)
fmt: format
black: format
format-check:
poetry run black --check $(format_targets)
# Linting
flake8:
poetry run flake8
flake: flake8
lint: flake8
# Type enforcement
mypy:
poetry run mypy --strict geneagrapher tests
types: mypy
# Tests
test:
poetry run pytest tests
# Images (for the README)
image-targets = images/chioniadis-geneagraph.png images/curry-geneagraph.png images/ryff-zwinger-geneagraph.png images/zwinger-geneagraph.png
images: $(image-targets)
chioniadis.dot: ids = 201288:a
curry.dot: ids = 7398:d
ryff-zwinger.dot: ids = 125148:a 130248:a
zwinger.dot: ids = 125148:a
$(image-targets): images/%-geneagraph.png: %.png
optipng -o5 $? -clobber -out $@
%.png: %.dot
dot -Tpng -Gdpi=150 $? > $@
%.dot:
poetry run python -m geneagrapher.geneagrapher $(ids) | sed s/‐/-/g > $@
clean-images:
rm -rf chioniadis.dot chioniadis.png curry.dot curry.png ryff-zwinger.dot ryff-zwinger.png zwinger.dot zwinger.png images/*-geneagraph.png.bak
all:
clean:
rm -rf dist
geneagrapher-2.0.0/README.md 0000664 0000000 0000000 00000014623 14420334703 0015426 0 ustar 00root root 0000000 0000000 # Geneagrapher [](https://github.com/davidalber/geneagrapher/actions/workflows/ci.yaml/badge.svg?branch=main)
Geneagrapher is a tool for building mathematician advisor-advisee
genealogies using information from the [Mathematics Genealogy
Project](https://www.mathgenealogy.org/). The output is either a DOT
file, which can be used by [Graphviz](https://graphviz.org/) to
visualize the graph, or a JSON structure that you can consume with
other software tools. Here's an example of a genealogy built by
Geneagrapher and visualized using Graphviz:
To use this package, you will need to have a Python interpreter on
your system and install this package. Additionally, if you want to
generate the graph visualization you will need another tool (e.g.,
[Graphviz](https://www.graphviz.org/)).
If you want to build a math genealogy more easily, you may want to
look at the [Geneagrapher
notebook](https://observablehq.com/@davidalber/geneagrapher). That
Observable notebook creates geneagraphs in your browser.
If you want to consume records from the Math Genealogy Project in your
own software, you may be interested in
[geneagrapher-core](https://github.com/davidalber/geneagrapher-core).
## Basic Concepts
The input to the Geneagrapher is a set of starting nodes and traversal
directions. Multiple starting nodes may be provided (to produce the
combined graph for an academic department's students and professors,
for instance).
### Starting Nodes
Each individual stored in the Mathematics Genealogy Project's website
has a unique integer as an identifier, and this identifier is what is
passed to the Geneagrapher to specify a starting node. The identifier
is contained in the URL for records in the Mathematics Genealogy
Project website. For example, [Carl
Gauß](https://www.mathgenealogy.org/id.php?id=18231) is ID 18231 and
[Leonhard Euler](https://www.mathgenealogy.org/id.php?id=38586) is ID
38586.
Before running the Geneagrapher, go to the [Mathematics Genealogy
Project](https://www.mathgenealogy.org/) and gather the identifiers of
the starting nodes for the graph you want to build.
### Traversal Directions
For each starting node, you instruct Geneagrapher to traverse in the
advisor direction, the descendant (i.e., student) direction, or
both. For example, if you want to build the graph of a mathematician
and all of their students, you would specify the descendant traversal
direction for that starting node.
### Syntax
When running Geneagrapher, you provide starting nodes on the command
line. The syntax for doing this is `NODE_ID:TRAVERSAL_DIRECTION`,
where `TRAVERSAL_DIRECTION` is `a | d`, and `a` and `d` indicate
advisor and descendant traversal, respectively. Here are some
examples:
- Carl Gauß and his advisor graph: `18231:a`.
- Carl Gauß and his descendant graph: `18231:d`.
- Carl Gauß and his advisor and descendant graphs: `18231:ad`.
## Installation
To install Geneagrapher, you must have Python >= 3.8.1. Geneagrapher
is installed by pip. If your system does not have pip, see the
instructions [here](https://pip.pypa.io/en/stable/installing/).
Once pip is available on your system, install Geneagrapher with:
```
pip install geneagrapher
```
## Usage
You can get help by doing
```
ggrapher --help
```
## Processing the DOT File
To process the generated DOT file,
[Graphviz](https://www.graphviz.org/) is needed. Graphviz installs
several programs for processing DOT files. For the Geneagrapher, use
the `dot` program. Let's look at an example.
If the Geneagrapher has generated a file named "graph.dot", a PNG file
containing the graph can be created with the following command.
```
dot -Tpng graph.dot > graph.png
```
That's really all there is to it. Almost.
By default, `dot` renders an image with 96dpi. This may not look great
on high-resolution displays, so you might want to increase the
resolution. You can do this with the `-Gdpi` flag. For instance, to
produce a PNG with 150dpi, you can do
```
dot -Tpng -Gdpi=150 graph.dot > graph.png
```
Graphviz can also generate other formats, such as PDF and SVG.
## Examples
Note: the Mathematics Genealogy Project data changes over time, so if
the examples below are re-run, the results may look different. The
commands, however, will be the same.
### Single Node Ancestry: Theodor Zwinger
To produce the ancestry DOT file for Theodor Zwinger and save it in
the file zwinger.dot, run the command
```
ggrapher -o zwinger.dot 125148:a
```

### Multiple Node Ancestry: Petrus Ryff and Theodor Zwinger
To produce the combined ancestry DOT file for Petrus Ryff and Theodor
Zwinger and save it in the file ryff_zwinger.dot, run the command
```
ggrapher -o ryff_zwinger.dot 125148:a 130248:a
```

### Single Node Descendant Graph: Haskell Curry
To produce the descendant DOT file for Haskell Curry and save it in
the file curry.dot, run the command
```
ggrapher -o curry.dot 7398:d
```

Note that descendant graphs often have a lot of "fan out".
## Technical Details
Previous versions of Geneagrapher made requests directly to the
Mathematics Genealogy Project and built the graph in the
application. The current version of Geneagrapher, however, makes
requests to an intermediate service that is built using
[geneagrapher-core](https://github.com/davidalber/geneagrapher-core). This
backend service assembles requested graphs and maintains a cache of
records.
While the shared cache substantially reduces the number of requests
from individuals running Geneagrapher (or the [Geneagrapher
notebook](https://observablehq.com/@davidalber/geneagrapher)) and
speeds up the graph-building process, it also creates an opportunity
for inconsistency between information in the Mathematics Genealogy
Project and the cache. This can happen when records are updated in the
Mathematics Genealogy Project. Such inconsistencies will automatically
be resolved when cached values expire.
geneagrapher-2.0.0/geneagrapher/ 0000775 0000000 0000000 00000000000 14420334703 0016571 5 ustar 00root root 0000000 0000000 geneagrapher-2.0.0/geneagrapher/__init__.py 0000664 0000000 0000000 00000000000 14420334703 0020670 0 ustar 00root root 0000000 0000000 geneagrapher-2.0.0/geneagrapher/geneagrapher.py 0000664 0000000 0000000 00000016717 14420334703 0021607 0 ustar 00root root 0000000 0000000 from .output.dot import DotOutput
from .output.identity import IdentityOutput
from .types import Geneagraph
from argparse import ArgumentParser, FileType
import asyncio
from importlib.metadata import PackageNotFoundError, version
import json
import platform
import textwrap
from typing import (
Any,
Dict,
List,
Literal,
Protocol,
Type,
TypedDict,
Union,
cast,
)
import re
import sys
import websockets
import websockets.client
GGRAPHER_URI = "wss://ggrphr.davidalber.net"
TEXTWRAP_WIDTH = 79
class OutputFormatter(Protocol):
"""This defines an interface that output classes must implement."""
def __init__(self, graph: Geneagraph) -> None:
...
@property
def output(self) -> str:
"""Return the graph's formatted output."""
...
class StartNodeRequest(TypedDict):
recordId: int
getAdvisors: bool
getDescendants: bool
class RequestPayload(TypedDict):
kind: Literal["build-graph"]
options: Dict[Literal["reportingCallback"], bool]
startNodes: List[StartNodeRequest]
class ProgressCallback(TypedDict):
queued: int
fetching: int
done: int
class GgrapherError(Exception):
def __init__(self, msg: str, *, extra: Dict[str, str] = {}) -> None:
self.msg = msg
self.extra = extra
def __str__(self) -> str:
ret_arr = [
textwrap.fill(self.msg, width=TEXTWRAP_WIDTH),
"",
textwrap.fill(
"If this problem persists, please create an issue at \
https://github.com/davidalber/geneagrapher/issues/new, and include the following in \
the issue body:",
width=TEXTWRAP_WIDTH,
),
]
# For the key-value arguments, determine the length of the
# longest key and use that information to align the columns.
extras_width = (
max([len(k) for k in ["Message", "Command"] + list(self.extra.keys())]) + 2
) # The 2 is for ": "
ret_arr.append(f"\n {'Message:':{extras_width}}{self.msg}")
ret_arr.append(f" {'Command:':{extras_width}}{' '.join(sys.argv)}")
for k, v in self.extra.items():
key = f"{k}:"
ret_arr.append(f" {key:{extras_width}}{v}")
return "\n".join(ret_arr)
class StartNodeArg:
def __init__(self, val: str) -> None:
# Validate the input.
match = re.fullmatch(r"(\d+)(:(a|d|ad|da))", val)
if match is None:
raise ValueError()
self.record_id = int(match.group(1))
self.request_advisors = "a" in (match.group(2) or [])
self.request_descendants = "d" in (match.group(2) or [])
# If no traverse direction was specified, default to advisors.
if not self.request_advisors and not self.request_descendants:
self.request_advisors = True
@property
def start_node(self) -> StartNodeRequest:
return {
"recordId": self.record_id,
"getAdvisors": self.request_advisors,
"getDescendants": self.request_descendants,
}
def make_payload(start_nodes: List[StartNodeArg], quiet: bool) -> RequestPayload:
return {
"kind": "build-graph",
"options": {"reportingCallback": not quiet},
"startNodes": [sn.start_node for sn in start_nodes],
}
def display_progress(queued: int, doing: int, done: int) -> None:
prefix = "Progress: "
size = 60
count = queued + doing + done
x = int(size * done / count)
y = int(size * doing / count)
print(
f"{prefix}[{u'█'*x}{u':'*y}{('.'*(size - x - y))}] {done}/{count}",
end="\r",
file=sys.stderr,
flush=True,
)
async def get_graph(payload: RequestPayload) -> Geneagraph:
def intify_record_keys(d: Dict[Any, Any]) -> Dict[Any, Any]:
"""JSON object keys are strings, but the Geneagraph type
expects the keys of the nodes object to be integers. This
function converts those keys to ints during deserialization.
"""
if "nodes" in d:
ret = {k: v for k, v in d.items() if k != "nodes"}
ret["nodes"] = {int(k): v for k, v in d["nodes"].items()}
return ret
return d
try:
async with websockets.client.connect(
GGRAPHER_URI,
user_agent_header=f"Python/{platform.python_version()} \
Geneagrapher/{get_version()}",
) as ws:
await ws.send(json.dumps(payload))
while True:
response_json = await ws.recv()
response = json.loads(response_json, object_hook=intify_record_keys)
response_payload: Union[
Geneagraph, ProgressCallback, None
] = response.get("payload")
if response["kind"] == "graph":
return cast(Geneagraph, response_payload)
elif response["kind"] == "progress":
progress = cast(ProgressCallback, response_payload)
display_progress(
progress["queued"], progress["fetching"], progress["done"]
)
else:
raise GgrapherError(
"Request to Geneagrapher backend failed.",
extra={"Response": str(response_json)},
)
except websockets.exceptions.WebSocketException:
raise GgrapherError("Geneagrapher backend is currently unavailable.")
def get_formatter(format: Literal["dot", "json"], graph: Geneagraph) -> OutputFormatter:
format_map: Dict[str, Type[OutputFormatter]] = {
"dot": DotOutput,
"json": IdentityOutput,
}
return format_map[format](graph)
def get_version() -> str:
try:
return version("geneagrapher")
except PackageNotFoundError:
return "dev"
def run() -> None:
description = 'Create a Graphviz "dot" file for a mathematics \
genealogy, where ID is a record identifier from the Mathematics Genealogy \
Project.'
parser = ArgumentParser(description=description)
parser.add_argument(
"-f",
"--format",
choices=("dot", "json"),
default="dot",
help="graph output format (default: dot)",
)
parser.add_argument(
"-o",
"--out",
dest="outfile",
help="write output to FILE [default: stdout]",
type=FileType("w"),
metavar="FILE",
default=sys.stdout,
)
parser.add_argument(
"-q",
"--quiet",
action="store_true",
default=False,
help="do not display the progress bar",
)
parser.add_argument(
"--version", action="version", version=f"%(prog)s {get_version()}"
)
parser.add_argument(
"ids",
metavar="ID",
type=StartNodeArg,
nargs="+",
help="mathematician record ID; valid formats are 'ID:a' for advisor \
traversal, 'ID:d' for descendant traversal, or 'ID:ad' for advisor and descendant \
traversal",
)
args = parser.parse_args()
payload = make_payload(args.ids, args.quiet)
async def build_graph() -> None:
graph = await get_graph(payload)
if not args.quiet:
# Output a line break to end the progress bar.
print(file=sys.stderr)
formatter: OutputFormatter = get_formatter(args.format, graph)
print(formatter.output, file=args.outfile)
try:
asyncio.run(build_graph())
except GgrapherError as e:
print(e, file=sys.stderr)
if __name__ == "__main__":
run()
geneagrapher-2.0.0/geneagrapher/output/ 0000775 0000000 0000000 00000000000 14420334703 0020131 5 ustar 00root root 0000000 0000000 geneagrapher-2.0.0/geneagrapher/output/dot.py 0000664 0000000 0000000 00000004067 14420334703 0021300 0 ustar 00root root 0000000 0000000 """This module implements `DotOutput`, a class that outputs a Graphviz
DOT file, given a `Geneagraph` object.
The DOT file syntax is generated by code in the module. A dependency,
such as [graphviz](https://pypi.org/project/graphviz/) could have been
used. It was decided to not take on a dependency since the DOT files
being generated in this project are very simple.
"""
from ..types import Geneagraph, Record
from typing import Generator
def make_node_str(record: Record) -> str:
label = record["name"]
institution = record["institution"]
year = record["year"]
if institution is not None or year is not None:
inst_comp = [institution] if institution is not None else []
year_comp = [f"({year})"] if year is not None else []
label += "\\n" + " ".join(inst_comp + year_comp)
return f'{record["id"]} [label="{label}"];'
def make_edge_str(record: Record, graph: Geneagraph) -> Generator[str, None, None]:
for advisor_id in filter(
lambda aid: aid in graph["nodes"],
set(
record["advisors"]
) # make `set` to eliminate the occasional duplicate advisor (e.g., at this
# time, 125886)
): # filter out advisors that are not part of the graph
yield f'{advisor_id} -> {record["id"]};'
class DotOutput:
def __init__(self, graph: Geneagraph) -> None:
self.graph = graph
@property
def output(self) -> str:
template = """digraph {{
graph [ordering="out"];
node [shape=plaintext];
edge [style=bold];
{nodes}
{edges}
}}"""
nodes = [
make_node_str(record)
for record in sorted(self.graph["nodes"].values(), key=lambda r: r["id"])
]
edges = [
edge_str
for record in sorted(
self.graph["nodes"].values(),
key=lambda r: (r["year"] or -10000, r["name"]),
)
for edge_str in make_edge_str(record, self.graph)
]
prefix = "\n "
return template.format(nodes=prefix.join(nodes), edges=prefix.join(edges))
geneagrapher-2.0.0/geneagrapher/output/identity.py 0000664 0000000 0000000 00000000622 14420334703 0022334 0 ustar 00root root 0000000 0000000 """This module implements `IdentityOutput`, a class that outputs a
Geneagraph JSON structure. This is simply the structure that is
returned by the Geneagrapher backend.
"""
from ..types import Geneagraph
import json
class IdentityOutput:
def __init__(self, graph: Geneagraph) -> None:
self.graph = graph
@property
def output(self) -> str:
return json.dumps(self.graph)
geneagrapher-2.0.0/geneagrapher/types.py 0000664 0000000 0000000 00000001022 14420334703 0020302 0 ustar 00root root 0000000 0000000 from typing import (
Dict,
List,
Literal,
NewType,
Optional,
TypedDict,
)
# RecordId, Record, and Geneagraph mirror types of the same name in
# geneagrapher-core.
RecordId = NewType("RecordId", int)
class Record(TypedDict):
id: RecordId
name: str
institution: Optional[str]
year: Optional[int]
descendants: List[int]
advisors: List[int]
class Geneagraph(TypedDict):
start_nodes: List[RecordId]
nodes: Dict[RecordId, Record]
status: Literal["complete", "truncated"]
geneagrapher-2.0.0/images/ 0000775 0000000 0000000 00000000000 14420334703 0015406 5 ustar 00root root 0000000 0000000 geneagrapher-2.0.0/images/chioniadis-geneagraph.png 0000664 0000000 0000000 00000122415 14420334703 0022332 0 ustar 00root root 0000000 0000000 PNG
IHDR bKGD ̿ IDATxwqtD# bE
boFT4Q$*c׀%*`5-6Dz{afwfwnn?`wfw|nfg1DDD$S
DDD"""JERDDDQ)""QT(*EDDDQ)""QT(*EDD"""JERDDDQ)"""JERDDDQ)""QT(*EDD"""JQT(*EDD"""JERD:1Ub)mۿ
/W
V+*EjYb4O2TT6p%]7e8C'x,okq`t6фpMJ!c'Rx\Q)RK̤-97TPֱ0c-b7!{4[bMȍ,J>)Lk4Q fay%F/+g`_ٮqlͯ6JZb.ؖk0
fA3Ėn:`_Q^d0mv7@1=b0˨XFq#rvBHZ7NI3_"y/SWX%Q
'<Ǻ/H>p~cw βG7](0:
=)O;&oZW~,8ڏtg֍]y6&EH^YM1{ vzT1{
żJ$6cx7LwDL0屇^SM0Wse x,O8.inɧrX6e%EJf2Gו<#KVoKN5]Xsë
/82|ͧ1:Jh&EH-v$EKZs.GPD#7c 9
83v\؆b+S1sөlTBߺ\C\xL@`*1f3`А)d^fw0Nw eQy/M0kx~{Oɵ:40DN0-8ľl"*+WӛIgjv<9Wݟt-NӉڤV7eb?ўK[< D.c W31Ŏ[0K5G95\߸Mb
1r=
++l0Fѐ_G+ /bE8V&cgW{2~4`Sfne;cro` L'Tv9[@s?JXOk;@9O\"ubm:Ys0b`2?2{+`[KƭFsp+H>f3ѿ>^،CPTI&r~x!m-h5]ߗZD=Dyǎ3O]qnxgO EHs7|\5fS~1~cK5nLD{ q?+8妯t3^c#Qw(*I: O`ߦ㞴8R
=<|{K*8vt?6ڮilTVv9ӻcoQo(*Ge9oRυeֹT:c?c@M[YhvLk,x_RW"Q|-F&1lAƎPVn9q{O*@TfJi;
8;FэHqGen^IǣDI9YmXBTv!f
t7frQikq~B~)L;n=1N8*+_镰[