ursaproxy 0.2.0__py3-none-any.whl → 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ursaproxy/__init__.py CHANGED
@@ -6,11 +6,23 @@ from jinja2 import Environment, PackageLoader
6
6
  from xitzin import NotFound, Request, Response, TemporaryFailure, Xitzin
7
7
 
8
8
  from .cache import cache
9
- from .config import settings
9
+ from .config import Settings, load_settings
10
10
  from .converter import extract_metadata, extract_slug, html_to_gemtext
11
11
  from .fetcher import NotFoundError, ServerError, fetch_feed, fetch_html
12
12
 
13
- app = Xitzin()
13
+ __all__ = ["create_app", "Settings", "load_settings"]
14
+
15
+
16
+ def _rfc822_to_iso(date_str: str) -> str:
17
+ """Convert RFC 822 date to ISO 8601 format for Atom feeds."""
18
+ if not date_str:
19
+ return datetime.now().isoformat() + "Z"
20
+ try:
21
+ dt = parsedate_to_datetime(date_str)
22
+ return dt.isoformat().replace("+00:00", "Z")
23
+ except (ValueError, TypeError):
24
+ return datetime.now().isoformat() + "Z"
25
+
14
26
 
15
27
  # Template environments
16
28
  templates = Environment(
@@ -24,177 +36,196 @@ xml_templates = Environment(
24
36
  )
25
37
 
26
38
 
27
- @app.on_startup
28
- async def startup() -> None:
29
- """Initialize shared HTTP client."""
30
- app.state.client = httpx.AsyncClient(timeout=30.0)
31
-
32
-
33
- @app.on_shutdown
34
- async def shutdown() -> None:
35
- """Close HTTP client."""
36
- await app.state.client.aclose()
37
-
38
-
39
- async def _get_feed(client: httpx.AsyncClient):
40
- """Fetch feed with caching and error handling."""
41
- if cached := cache.get("feed", settings.cache_ttl_feed):
42
- return cached
43
-
44
- try:
45
- feed = await fetch_feed(client)
46
- cache.set("feed", feed)
47
- return feed
48
- except ServerError as e:
49
- raise TemporaryFailure(str(e)) from e
50
- except NotFoundError as e:
51
- raise NotFound(str(e)) from e
52
-
53
-
54
- async def _render_content(
55
- client: httpx.AsyncClient,
56
- slug: str,
57
- content_type: str,
58
- include_date: bool = True,
59
- ) -> str:
60
- """Fetch and render content as gemtext with caching."""
61
- cache_key = f"{content_type}:{slug}"
62
-
63
- if cached := cache.get(cache_key, settings.cache_ttl_post):
64
- return cached
65
-
66
- try:
67
- html = await fetch_html(client, slug)
68
- except NotFoundError as e:
69
- raise NotFound(str(e)) from e
70
- except ServerError as e:
71
- raise TemporaryFailure(str(e)) from e
72
-
73
- title, date = extract_metadata(html)
74
- content = html_to_gemtext(html)
75
-
76
- template = templates.get_template("post.gmi")
77
- gemtext = template.render(
78
- title=title,
79
- date=date if include_date else None,
80
- content=content,
81
- web_url=f"{settings.bearblog_url}/{slug}/",
82
- )
83
-
84
- cache.set(cache_key, gemtext)
85
- return gemtext
86
-
87
-
88
- @app.gemini("/")
89
- async def index(request: Request) -> str:
90
- """Landing page with recent posts and page links."""
91
- feed = await _get_feed(request.app.state.client)
92
-
93
- posts = []
94
- for entry in feed.entries[:10]:
95
- link = getattr(entry, "link", None)
96
- if not link:
97
- continue
98
- slug = extract_slug(link)
99
- if not slug:
100
- continue
101
- date = entry.get("published", "")[:16] if entry.get("published") else ""
102
- title = getattr(entry, "title", "Untitled")
103
- posts.append({"slug": slug, "title": title, "date": date})
104
-
105
- template = templates.get_template("index.gmi")
106
- return template.render(
107
- blog_name=settings.blog_name,
108
- description=feed.feed.get("description", ""),
109
- pages=settings.pages,
110
- posts=posts,
111
- )
112
-
113
-
114
- @app.gemini("/post/{slug}")
115
- async def post(request: Request, slug: str) -> str:
116
- """Individual blog post."""
117
- return await _render_content(
118
- request.app.state.client, slug, "post", include_date=True
119
- )
120
-
121
-
122
- @app.gemini("/page/{slug}")
123
- async def page(request: Request, slug: str) -> str:
124
- """Static page (projects, now, etc.)."""
125
- return await _render_content(
126
- request.app.state.client, slug, "page", include_date=False
127
- )
128
-
129
-
130
- @app.gemini("/about")
131
- async def about(request: Request) -> str:
132
- """About page from feed metadata."""
133
- feed = await _get_feed(request.app.state.client)
134
- description = feed.feed.get("description", "A personal blog.")
39
+ def create_app(settings: Settings) -> Xitzin:
40
+ """Create and configure an UrsaProxy application instance.
41
+
42
+ This factory function allows ursaproxy to be embedded in other Xitzin apps
43
+ for virtual hosting scenarios.
44
+
45
+ Args:
46
+ settings: Configuration for this ursaproxy instance.
47
+
48
+ Returns:
49
+ A configured Xitzin application.
50
+
51
+ Example:
52
+ For standalone use::
53
+
54
+ from ursaproxy import create_app, load_settings
55
+
56
+ app = create_app(load_settings())
57
+ app.run()
58
+
59
+ For embedding in another app::
60
+
61
+ from ursaproxy import create_app, Settings
62
+
63
+ ursaproxy_settings = Settings(
64
+ bearblog_url="https://myblog.bearblog.dev",
65
+ blog_name="My Blog",
66
+ )
67
+ ursaproxy_app = create_app(ursaproxy_settings)
68
+ # Mount ursaproxy_app in your main Xitzin app
69
+ """
70
+ app = Xitzin()
71
+ app.state.settings = settings
72
+
73
+ @app.on_startup
74
+ async def startup() -> None:
75
+ """Initialize shared HTTP client."""
76
+ app.state.client = httpx.AsyncClient(timeout=30.0)
77
+
78
+ @app.on_shutdown
79
+ async def shutdown() -> None:
80
+ """Close HTTP client."""
81
+ await app.state.client.aclose()
82
+
83
+ async def _get_feed(client: httpx.AsyncClient):
84
+ """Fetch feed with caching and error handling."""
85
+ if cached := cache.get("feed", settings.cache_ttl_feed):
86
+ return cached
87
+
88
+ try:
89
+ feed = await fetch_feed(client, settings.bearblog_url)
90
+ cache.set("feed", feed)
91
+ return feed
92
+ except ServerError as e:
93
+ raise TemporaryFailure(str(e)) from e
94
+ except NotFoundError as e:
95
+ raise NotFound(str(e)) from e
96
+
97
+ async def _render_content(
98
+ client: httpx.AsyncClient,
99
+ slug: str,
100
+ content_type: str,
101
+ include_date: bool = True,
102
+ ) -> str:
103
+ """Fetch and render content as gemtext with caching."""
104
+ cache_key = f"{content_type}:{slug}"
105
+
106
+ if cached := cache.get(cache_key, settings.cache_ttl_post):
107
+ return cached
108
+
109
+ try:
110
+ html = await fetch_html(client, settings.bearblog_url, slug)
111
+ except NotFoundError as e:
112
+ raise NotFound(str(e)) from e
113
+ except ServerError as e:
114
+ raise TemporaryFailure(str(e)) from e
115
+
116
+ title, date = extract_metadata(html)
117
+ content = html_to_gemtext(html)
118
+
119
+ template = templates.get_template("post.gmi")
120
+ gemtext = template.render(
121
+ title=title,
122
+ date=date if include_date else None,
123
+ content=content,
124
+ web_url=f"{settings.bearblog_url}/{slug}/",
125
+ )
135
126
 
136
- template = templates.get_template("about.gmi")
137
- return template.render(
138
- blog_name=settings.blog_name,
139
- description=description,
140
- bearblog_url=settings.bearblog_url,
141
- )
127
+ cache.set(cache_key, gemtext)
128
+ return gemtext
129
+
130
+ @app.gemini("/")
131
+ async def index(request: Request) -> str:
132
+ """Landing page with recent posts and page links."""
133
+ feed = await _get_feed(request.app.state.client)
134
+
135
+ posts = []
136
+ for entry in feed.entries[:10]:
137
+ link = getattr(entry, "link", None)
138
+ if not link:
139
+ continue
140
+ slug = extract_slug(link)
141
+ if not slug:
142
+ continue
143
+ date = entry.get("published", "")[:16] if entry.get("published") else ""
144
+ title = getattr(entry, "title", "Untitled")
145
+ posts.append({"slug": slug, "title": title, "date": date})
146
+
147
+ template = templates.get_template("index.gmi")
148
+ return template.render(
149
+ blog_name=settings.blog_name,
150
+ description=feed.feed.get("description", ""),
151
+ pages=settings.pages,
152
+ posts=posts,
153
+ )
142
154
 
155
+ @app.gemini("/post/{slug}")
156
+ async def post(request: Request, slug: str) -> str:
157
+ """Individual blog post."""
158
+ return await _render_content(
159
+ request.app.state.client, slug, "post", include_date=True
160
+ )
143
161
 
144
- def _rfc822_to_iso(date_str: str) -> str:
145
- """Convert RFC 822 date to ISO 8601 format for Atom feeds."""
146
- if not date_str:
147
- return datetime.now().isoformat() + "Z"
148
- try:
149
- dt = parsedate_to_datetime(date_str)
150
- return dt.isoformat().replace("+00:00", "Z")
151
- except (ValueError, TypeError):
152
- return datetime.now().isoformat() + "Z"
162
+ @app.gemini("/page/{slug}")
163
+ async def page(request: Request, slug: str) -> str:
164
+ """Static page (projects, now, etc.)."""
165
+ return await _render_content(
166
+ request.app.state.client, slug, "page", include_date=False
167
+ )
153
168
 
169
+ @app.gemini("/about")
170
+ async def about(request: Request) -> str:
171
+ """About page from feed metadata."""
172
+ feed = await _get_feed(request.app.state.client)
173
+ description = feed.feed.get("description", "A personal blog.")
174
+
175
+ template = templates.get_template("about.gmi")
176
+ return template.render(
177
+ blog_name=settings.blog_name,
178
+ description=description,
179
+ bearblog_url=settings.bearblog_url,
180
+ )
154
181
 
155
- @app.gemini("/feed")
156
- async def feed(request: Request) -> Response:
157
- """Atom feed with Gemini URLs."""
158
- rss = await _get_feed(request.app.state.client)
159
-
160
- # Use configured gemini_host or fall back to request hostname
161
- host = settings.gemini_host or request.hostname or "localhost"
162
- base_url = f"gemini://{host}"
163
-
164
- # Get the most recent update time
165
- updated = _rfc822_to_iso(rss.feed.get("updated", ""))
166
-
167
- entries = []
168
- for entry in rss.entries:
169
- link = getattr(entry, "link", None)
170
- if not link:
171
- continue
172
- slug = extract_slug(link)
173
- if not slug:
174
- continue
175
-
176
- entries.append(
177
- {
178
- "title": getattr(entry, "title", "Untitled"),
179
- "url": f"{base_url}/post/{slug}",
180
- "published": _rfc822_to_iso(entry.get("published", "")),
181
- "summary": getattr(entry, "description", ""),
182
- }
182
+ @app.gemini("/feed")
183
+ async def feed_route(request: Request) -> Response:
184
+ """Atom feed with Gemini URLs."""
185
+ rss = await _get_feed(request.app.state.client)
186
+
187
+ # Use configured gemini_host or fall back to request hostname
188
+ host = settings.gemini_host or request.hostname or "localhost"
189
+ base_url = f"gemini://{host}"
190
+
191
+ # Get the most recent update time
192
+ updated = _rfc822_to_iso(rss.feed.get("updated", ""))
193
+
194
+ entries = []
195
+ for entry in rss.entries:
196
+ link = getattr(entry, "link", None)
197
+ if not link:
198
+ continue
199
+ slug = extract_slug(link)
200
+ if not slug:
201
+ continue
202
+
203
+ entries.append(
204
+ {
205
+ "title": getattr(entry, "title", "Untitled"),
206
+ "url": f"{base_url}/post/{slug}",
207
+ "published": _rfc822_to_iso(entry.get("published", "")),
208
+ "summary": getattr(entry, "description", ""),
209
+ }
210
+ )
211
+
212
+ template = xml_templates.get_template("feed.xml")
213
+ atom_xml = template.render(
214
+ blog_name=settings.blog_name,
215
+ base_url=base_url,
216
+ updated=updated,
217
+ entries=entries,
183
218
  )
184
219
 
185
- template = xml_templates.get_template("feed.xml")
186
- atom_xml = template.render(
187
- blog_name=settings.blog_name,
188
- base_url=base_url,
189
- updated=updated,
190
- entries=entries,
191
- )
220
+ return Response(body=atom_xml, mime_type="application/atom+xml")
192
221
 
193
- return Response(body=atom_xml, mime_type="application/atom+xml")
222
+ return app
194
223
 
195
224
 
196
225
  def main() -> None:
197
- """Entry point."""
226
+ """Entry point for standalone execution."""
227
+ settings = load_settings()
228
+ app = create_app(settings)
198
229
  app.run(
199
230
  host=settings.host,
200
231
  port=settings.port,
ursaproxy/config.py CHANGED
@@ -74,8 +74,12 @@ class Settings(BaseSettings):
74
74
  return v
75
75
 
76
76
 
77
- def _load_settings() -> Settings:
78
- """Load settings, handling missing TOML file gracefully."""
77
+ def load_settings() -> Settings:
78
+ """Load settings from TOML file and/or environment variables.
79
+
80
+ Use this when running ursaproxy standalone. For embedding in other apps,
81
+ create a Settings instance directly with the desired configuration.
82
+ """
79
83
  try:
80
84
  return Settings() # type: ignore[call-arg]
81
85
  except FileNotFoundError:
@@ -93,6 +97,3 @@ def _load_settings() -> Settings:
93
97
  return (env_settings,)
94
98
 
95
99
  return _EnvOnlySettings() # type: ignore[call-arg]
96
-
97
-
98
- settings = _load_settings()
ursaproxy/fetcher.py CHANGED
@@ -1,8 +1,6 @@
1
1
  import feedparser
2
2
  import httpx
3
3
 
4
- from .config import settings
5
-
6
4
 
7
5
  class FetchError(Exception):
8
6
  """Base error for fetch operations."""
@@ -35,19 +33,30 @@ async def _fetch(
35
33
  raise ServerError(f"Network error: {e}") from e
36
34
 
37
35
 
38
- async def fetch_feed(client: httpx.AsyncClient) -> feedparser.FeedParserDict:
39
- """Fetch RSS feed from Bearblog."""
40
- url = f"{settings.bearblog_url}/feed/?type=rss"
36
+ async def fetch_feed(
37
+ client: httpx.AsyncClient, bearblog_url: str
38
+ ) -> feedparser.FeedParserDict:
39
+ """Fetch RSS feed from Bearblog.
40
+
41
+ Args:
42
+ client: HTTP client instance.
43
+ bearblog_url: Base URL of the Bearblog site.
44
+ """
45
+ url = f"{bearblog_url}/feed/?type=rss"
41
46
  response = await _fetch(client, url, f"Feed not found at {url}")
42
47
  return feedparser.parse(response.text)
43
48
 
44
49
 
45
- async def fetch_html(client: httpx.AsyncClient, slug: str) -> str:
46
- """
47
- Fetch HTML page from Bearblog.
50
+ async def fetch_html(client: httpx.AsyncClient, bearblog_url: str, slug: str) -> str:
51
+ """Fetch HTML page from Bearblog.
52
+
53
+ Args:
54
+ client: HTTP client instance.
55
+ bearblog_url: Base URL of the Bearblog site.
56
+ slug: Page slug (without leading/trailing slashes).
48
57
 
49
58
  Note: Bearblog URLs have trailing slashes: /{slug}/
50
59
  """
51
- url = f"{settings.bearblog_url}/{slug}/"
60
+ url = f"{bearblog_url}/{slug}/"
52
61
  response = await _fetch(client, url, f"Page not found: {slug}")
53
62
  return response.text
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: ursaproxy
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: A Bearblog-to-Gemini proxy showcasing Xitzin
5
5
  Author: Alan Velasco
6
6
  Author-email: Alan Velasco <ursaproxy@alanbato.com>
@@ -44,47 +44,93 @@ pip install ursaproxy
44
44
 
45
45
  ## Configuration
46
46
 
47
- UrsaProxy is configured via environment variables:
47
+ UrsaProxy can be configured via a TOML file or environment variables.
48
48
 
49
- ### Required
49
+ ### TOML Configuration (Recommended)
50
50
 
51
- | Variable | Description |
52
- |----------|-------------|
53
- | `BEARBLOG_URL` | The Bearblog URL to proxy (e.g., `https://example.bearblog.dev`) |
54
- | `BLOG_NAME` | Display name for the blog |
55
- | `CERT_FILE` | Path to TLS certificate file |
56
- | `KEY_FILE` | Path to TLS private key file |
51
+ Create an `ursaproxy.toml` file in your working directory:
57
52
 
58
- ### Optional
53
+ ```toml
54
+ bearblog_url = "https://example.bearblog.dev"
55
+ blog_name = "My Gemini Blog"
56
+ cert_file = "/path/to/cert.pem"
57
+ key_file = "/path/to/key.pem"
59
58
 
60
- | Variable | Default | Description |
61
- |----------|---------|-------------|
62
- | `PAGES` | `{}` | JSON dict of static pages `{"slug": "Title"}` |
63
- | `GEMINI_HOST` | `None` | Hostname for Gemini URLs in feed |
64
- | `CACHE_TTL_FEED` | `300` | Feed cache TTL in seconds (5 min) |
65
- | `CACHE_TTL_POST` | `1800` | Post cache TTL in seconds (30 min) |
66
- | `HOST` | `localhost` | Server bind address |
67
- | `PORT` | `1965` | Server port (Gemini default) |
59
+ # Optional settings
60
+ gemini_host = "gemini.example.com"
61
+ cache_ttl_feed = 300
62
+ cache_ttl_post = 1800
63
+ host = "localhost"
64
+ port = 1965
68
65
 
69
- ### Example
70
-
71
- ```bash
72
- export BEARBLOG_URL="https://example.bearblog.dev"
73
- export BLOG_NAME="My Gemini Blog"
74
- export CERT_FILE="/path/to/cert.pem"
75
- export KEY_FILE="/path/to/key.pem"
76
- export PAGES='{"about": "About Me", "now": "What I am doing now"}'
77
- export GEMINI_HOST="gemini.example.com"
66
+ [pages]
67
+ about = "About Me"
68
+ now = "What I am doing now"
78
69
  ```
79
70
 
71
+ ### Environment Variables
72
+
73
+ Environment variables override TOML settings:
74
+
75
+ | Variable | Required | Default | Description |
76
+ |----------|----------|---------|-------------|
77
+ | `BEARBLOG_URL` | Yes | - | The Bearblog URL to proxy |
78
+ | `BLOG_NAME` | Yes | - | Display name for the blog |
79
+ | `CERT_FILE` | No | `None` | Path to TLS certificate file |
80
+ | `KEY_FILE` | No | `None` | Path to TLS private key file |
81
+ | `PAGES` | No | `{}` | JSON dict of static pages |
82
+ | `GEMINI_HOST` | No | `None` | Hostname for Gemini URLs in feed |
83
+ | `CACHE_TTL_FEED` | No | `300` | Feed cache TTL in seconds |
84
+ | `CACHE_TTL_POST` | No | `1800` | Post cache TTL in seconds |
85
+ | `HOST` | No | `localhost` | Server bind address |
86
+ | `PORT` | No | `1965` | Server port |
87
+
88
+ ### Configuration Priority
89
+
90
+ Settings are loaded in this order (highest to lowest priority):
91
+
92
+ 1. Environment variables
93
+ 2. `ursaproxy.toml` file
94
+ 3. Default values
95
+
80
96
  ## Usage
81
97
 
98
+ ### Standalone
99
+
82
100
  ```bash
83
101
  ursaproxy
84
102
  ```
85
103
 
86
104
  The server will start on `gemini://localhost:1965/` by default.
87
105
 
106
+ ### As a Library
107
+
108
+ UrsaProxy can be embedded in other Xitzin applications for virtual hosting:
109
+
110
+ ```python
111
+ from ursaproxy import create_app, Settings
112
+
113
+ # Create settings programmatically
114
+ settings = Settings(
115
+ bearblog_url="https://myblog.bearblog.dev",
116
+ blog_name="My Blog",
117
+ )
118
+
119
+ # Create the app
120
+ app = create_app(settings)
121
+
122
+ # Mount in your main Xitzin app or run directly
123
+ app.run(certfile="cert.pem", keyfile="key.pem")
124
+ ```
125
+
126
+ Or load settings from TOML/environment:
127
+
128
+ ```python
129
+ from ursaproxy import create_app, load_settings
130
+
131
+ app = create_app(load_settings())
132
+ ```
133
+
88
134
  ### Routes
89
135
 
90
136
  | Route | Description |
@@ -151,7 +197,7 @@ src/ursaproxy/
151
197
  The test suite uses pytest with fixtures for offline testing:
152
198
 
153
199
  ```bash
154
- # Run all 111 tests
200
+ # Run all tests
155
201
  uv run pytest
156
202
 
157
203
  # Run specific test file
@@ -1,13 +1,13 @@
1
- ursaproxy/__init__.py,sha256=bO2ym_YzTMOUVBbqTjXWWI0ZQqRc81HnZ2o0JBkkZso,5777
1
+ ursaproxy/__init__.py,sha256=6YbaViq9c7W-s4iQlNkOSEY_0QFIy_3rOh37nE0qWw8,7433
2
2
  ursaproxy/cache.py,sha256=Q5cypE91xGte1ph-_NqcEwZ0NoT7_LszYT1DVpfd2bM,1205
3
- ursaproxy/config.py,sha256=j9QOFB1a-VaEYkr_stuxFPyG5D7dKY10h5YBQTNYMjs,3191
3
+ ursaproxy/config.py,sha256=_qIVgLITf39zqieWwHlMBpFROjWgt7FZt22Z4rgfx9k,3320
4
4
  ursaproxy/converter.py,sha256=FAW0fA7a3WtlPYMtZbDlsM0Gl7twXK73DAV911R7SPI,1955
5
- ursaproxy/fetcher.py,sha256=1Bsm96QYjnKQMorS2xwp9e3WRq8GbA6tKZrs1Z4ObOM,1591
5
+ ursaproxy/fetcher.py,sha256=Qj-1bSG0nfqtL30fdVI-yITYcNSj3IEmNP1W9Z964fs,1851
6
6
  ursaproxy/templates/about.gmi,sha256=hJxK25i9uXr2geBo1HrdkLztqkebICDy-_bdnQ4jlGI,105
7
7
  ursaproxy/templates/feed.xml,sha256=0aiFNOfgHeMH0427LPc58pEf0NdvcDHIRa-x9tc_Ty8,597
8
8
  ursaproxy/templates/index.gmi,sha256=2J-1lQRcwoxr46bqasW969RPpo_X5n2sh9sVuXIMdFg,265
9
9
  ursaproxy/templates/post.gmi,sha256=DMPbqZl52v-G-6wmwbXh15kg8dYpTDUx3mfqkce-HhQ,133
10
- ursaproxy-0.2.0.dist-info/WHEEL,sha256=iHtWm8nRfs0VRdCYVXocAWFW8ppjHL-uTJkAdZJKOBM,80
11
- ursaproxy-0.2.0.dist-info/entry_points.txt,sha256=uM3A9bQS-p_6VhWbkFRtob2oN-SuBboJWS3ZwCjlAFk,46
12
- ursaproxy-0.2.0.dist-info/METADATA,sha256=zwq7-OQSXqWywFSbsiMR0oDuveuPIGzfiqRglvVJZUc,4781
13
- ursaproxy-0.2.0.dist-info/RECORD,,
10
+ ursaproxy-0.3.0.dist-info/WHEEL,sha256=iHtWm8nRfs0VRdCYVXocAWFW8ppjHL-uTJkAdZJKOBM,80
11
+ ursaproxy-0.3.0.dist-info/entry_points.txt,sha256=uM3A9bQS-p_6VhWbkFRtob2oN-SuBboJWS3ZwCjlAFk,46
12
+ ursaproxy-0.3.0.dist-info/METADATA,sha256=oUATQ02pD-CxrGO2YkJLNyJVzDWTL1uS1y3oJiV0SZw,5682
13
+ ursaproxy-0.3.0.dist-info/RECORD,,