Skip to content

Commit 4b48101

Browse files
committed
Merging updates from small-fix commit.
2 parents 8129472 + 7fc56e7 commit 4b48101

30 files changed

Lines changed: 511 additions & 91 deletions

File tree

‎.gitignore‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,5 +40,3 @@ learning_observer/learning_observer/static_data/admins.yaml
4040
.ipynb_checkpoints/
4141
.eggs/
4242
.next/
43-
modules/wo_portfolio_diff/wo_portfolio_diff/portfolio_diff/*
44-

‎Makefile‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,8 @@ install: install-pre-commit-hook
4646
# recent version. We would like to directly fetch `lodrc-current`,
4747
# however, the fetch only returns the name of the file it's
4848
# linked to. We do an additional fetch for the linked file.
49-
@LODRC_CURRENT=$$(curl -s https://raw.githubusercontent.com/ETS-Next-Gen/lo_assets/main/lo_dash_react_components/lo_dash_react_components-current.tar.gz); \
50-
pip install https://raw.githubusercontent.com/ETS-Next-Gen/lo_assets/main/lo_dash_react_components/$${LODRC_CURRENT}
49+
@LODRC_CURRENT=$$(curl -s https://raw.githubusercontent.com/ArgLab/lo_assets/main/lo_dash_react_components/lo_dash_react_components-current.tar.gz); \
50+
pip install https://raw.githubusercontent.com/ArgLab/lo_assets/main/lo_dash_react_components/$${LODRC_CURRENT}
5151

5252
install-dev:
5353
# TODO create a dev requirements file

‎VERSION‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1 @@
1-
0.1.0+2026.03.06T16.07.48.997Z.0bfff3a0.berickson.20260303.copy.paste.reducer
2-
1+
0.1.0+2026.03.16T14.11.00.133Z.ed3b6ac0.berickson.20260312.small.fixes

‎learning_observer/VERSION‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.1.0+2026.02.27T21.25.37.849Z.3207a114.berickson.20260220.dami.portfolio.pr
1+
0.1.0+2026.03.16T14.11.00.133Z.ed3b6ac0.berickson.20260312.small.fixes
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
"""Simple local lorem ipsum text generation utilities."""
2+
3+
from itertools import cycle, islice
4+
5+
_LOREM_SENTENCES = [
6+
"Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
7+
"Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
8+
"Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.",
9+
"Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.",
10+
"Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.",
11+
"Integer nec odio praesent libero sed cursus ante dapibus diam.",
12+
"Nam dui ligula fringilla a euismod sodales sollicitudin vel wisi.",
13+
"Aenean fermentum risus id tortor fusce tellus odio dapibus id fermentum quis suscipit id erat.",
14+
"Etiam posuere lacus quis dolor pellentesque egestas.",
15+
"Curabitur vitae diam non enim vestibulum interdum.",
16+
]
17+
18+
19+
def _paragraph(sentence_count=5, offset=0):
20+
sentences = islice(cycle(_LOREM_SENTENCES), offset, offset + sentence_count)
21+
return " ".join(sentences)
22+
23+
24+
def get_paragraphs(paragraph_count, sentence_count=5):
25+
"""Return a list of lorem ipsum paragraphs.
26+
27+
Args:
28+
paragraph_count (int): Number of paragraphs to generate.
29+
sentence_count (int): Number of sentences per paragraph.
30+
"""
31+
paragraph_count = max(0, int(paragraph_count))
32+
sentence_count = max(1, int(sentence_count))
33+
return [_paragraph(sentence_count=sentence_count, offset=i) for i in range(paragraph_count)]
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
"""
2+
learning_observer.remote_assets
3+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
4+
5+
Utilities for downloading and installing prebuilt frontend asset bundles
6+
(e.g. exported Next.js apps) that are hosted in a GitHub repository.
7+
8+
Intended use: LO module ``__init__.py`` files call :func:`fetch_module_assets`
9+
from their startup checks to ensure their bundled frontend is present.
10+
11+
Typical repository layout assumed by the pointer-file convention::
12+
13+
lo_assets/
14+
my_module/
15+
my_module-current.tar.gz ← plain-text file containing the tarball name
16+
my_module-v1.2.3.tar.gz ← the actual tarball
17+
"""
18+
19+
import os
20+
import shutil
21+
import sys
22+
import tarfile
23+
import tempfile
24+
import urllib.request
25+
26+
# ---------------------------------------------------------------------------
27+
# Default repository coordinates
28+
# ---------------------------------------------------------------------------
29+
30+
_LO_ASSETS_REPO = 'ArgLab/lo_assets'
31+
_LO_ASSETS_REF = 'main'
32+
33+
34+
# ---------------------------------------------------------------------------
35+
# Interactive / non-interactive prompts
36+
# ---------------------------------------------------------------------------
37+
38+
def confirm(prompt, default_noninteractive):
39+
"""
40+
Ask a yes/no question and return a boolean.
41+
42+
Resolution order:
43+
1. *default_noninteractive* when stdin is not a TTY.
44+
2. An interactive prompt otherwise.
45+
46+
Example usage in a module startup check::
47+
48+
should_fetch = confirm(
49+
prompt='Download missing assets? (y/n) ',
50+
default_noninteractive=False,
51+
)
52+
"""
53+
if not sys.stdin.isatty():
54+
return default_noninteractive
55+
56+
return input(prompt).strip().lower() in {'y', 'yes'}
57+
58+
59+
# ---------------------------------------------------------------------------
60+
# Low-level network helpers
61+
# ---------------------------------------------------------------------------
62+
63+
def read_url(url):
64+
"""
65+
Fetch *url* and return its contents as a stripped string.
66+
"""
67+
with urllib.request.urlopen(url) as response:
68+
return response.read().decode('utf-8').strip()
69+
70+
71+
def download_file(url, destination):
72+
"""
73+
Stream *url* to *destination*, avoiding loading the full response into
74+
memory.
75+
"""
76+
with urllib.request.urlopen(url) as response, open(destination, 'wb') as output_file:
77+
shutil.copyfileobj(response, output_file)
78+
79+
80+
# ---------------------------------------------------------------------------
81+
# Archive helpers
82+
# ---------------------------------------------------------------------------
83+
84+
def find_nextjs_root(extracted_dir):
85+
"""
86+
Walk *extracted_dir* and return the shallowest path that looks like an
87+
exported Next.js app (contains both ``index.html`` and a ``_next/``
88+
directory).
89+
90+
Raises ``ValueError`` if no such directory is found.
91+
"""
92+
candidates = []
93+
for root, _, files in os.walk(extracted_dir):
94+
if 'index.html' in files and os.path.isdir(os.path.join(root, '_next')):
95+
candidates.append(root)
96+
97+
if not candidates:
98+
raise ValueError(
99+
"Could not find an exported Next.js app inside the archive "
100+
"(expected index.html and _next/ in the same directory)."
101+
)
102+
103+
return min(candidates, key=len)
104+
105+
106+
def extract_assets_tarball(tar_path, target_dir):
107+
"""
108+
Extract the Next.js app bundle at *tar_path* into *target_dir*.
109+
110+
The archive may contain arbitrary wrapper directories; only the shallowest
111+
directory that looks like an exported Next.js app is copied to
112+
*target_dir*. Any pre-existing *target_dir* is removed first.
113+
114+
Raises ``ValueError`` (from :func:`find_nextjs_root`) if the archive does
115+
not contain a recognisable Next.js export.
116+
"""
117+
with tempfile.TemporaryDirectory() as temp_extract_dir:
118+
with tarfile.open(tar_path, mode='r:gz') as archive:
119+
archive.extractall(temp_extract_dir)
120+
121+
source_root = find_nextjs_root(temp_extract_dir)
122+
123+
if os.path.isdir(target_dir):
124+
shutil.rmtree(target_dir)
125+
shutil.copytree(source_root, target_dir)
126+
127+
128+
# ---------------------------------------------------------------------------
129+
# High-level fetch interface
130+
# ---------------------------------------------------------------------------
131+
132+
def fetch_module_assets(
133+
target_dir,
134+
pointer_file,
135+
assets_url=None,
136+
assets_repo=_LO_ASSETS_REPO,
137+
assets_ref=_LO_ASSETS_REF,
138+
):
139+
"""
140+
Download and install a prebuilt Next.js asset bundle for an LO module.
141+
142+
Parameters
143+
----------
144+
target_dir:
145+
Local directory where the extracted app should be placed.
146+
pointer_file:
147+
Path within *assets_repo* to a plain-text file whose content is the
148+
filename of the current tarball, e.g.
149+
``'wo_portfolio_diff/wo_portfolio_diff-current.tar.gz'``.
150+
The tarball is assumed to live in the same directory as this file.
151+
assets_url:
152+
If supplied, skip pointer-file resolution and download this URL
153+
directly. Useful for pinning a specific release or local testing.
154+
assets_repo:
155+
``owner/repo`` slug on GitHub. Defaults to the canonical LO assets
156+
repository.
157+
assets_ref:
158+
Branch, tag, or commit SHA to resolve *pointer_file* against.
159+
Defaults to ``'main'``.
160+
161+
Raises
162+
------
163+
urllib.error.URLError
164+
Network error while resolving the pointer file or downloading.
165+
ValueError
166+
The downloaded archive does not contain a recognisable Next.js export.
167+
OSError
168+
File-system error while writing or extracting the archive.
169+
tarfile.TarError
170+
The downloaded file is not a valid gzipped tar archive.
171+
"""
172+
if assets_url is None:
173+
pointer_url = (
174+
f'https://raw.githubusercontent.com/{assets_repo}/{assets_ref}'
175+
f'/{pointer_file}'
176+
)
177+
resolved_name = read_url(pointer_url)
178+
pointer_dir = pointer_file.rsplit('/', 1)[0]
179+
assets_url = (
180+
f'https://raw.githubusercontent.com/{assets_repo}/{assets_ref}'
181+
f'/{pointer_dir}/{resolved_name}'
182+
)
183+
184+
with tempfile.NamedTemporaryFile(suffix='.tar.gz', delete=False) as tmp:
185+
archive_path = tmp.name
186+
187+
try:
188+
download_file(assets_url, archive_path)
189+
extract_assets_tarball(archive_path, target_dir)
190+
finally:
191+
os.unlink(archive_path)

‎learning_observer/learning_observer/routes.py‎

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -480,8 +480,22 @@ async def _nextjs_handler(request):
480480
sub_url = request.match_info.get('tail')
481481
if sub_url is None:
482482
return aiohttp.web.FileResponse(os.path.join(path, 'index.html'))
483-
# TODO will this handle multi-layered sub-urls? /foo/bar
484-
return aiohttp.web.FileResponse(os.path.join(path, f'{sub_url}.html'))
483+
# Normalize the subpath and ensure it stays in the exported Next.js folder.
484+
# This allows exported assets like `runtime-config.js` to be served directly.
485+
relative_path = sub_url.lstrip('/')
486+
file_path = os.path.normpath(os.path.join(path, relative_path))
487+
if os.path.commonpath([os.path.abspath(file_path), os.path.abspath(path)]) != os.path.abspath(path):
488+
raise aiohttp.web.HTTPNotFound()
489+
490+
if os.path.isfile(file_path):
491+
return aiohttp.web.FileResponse(file_path)
492+
493+
# Handle extension-less routes exported as html files.
494+
html_path = os.path.normpath(os.path.join(path, f'{relative_path}.html'))
495+
if os.path.commonpath([os.path.abspath(html_path), os.path.abspath(path)]) != os.path.abspath(path):
496+
raise aiohttp.web.HTTPNotFound()
497+
498+
return aiohttp.web.FileResponse(html_path)
485499
return _nextjs_handler
486500

487501

@@ -501,6 +515,11 @@ def register_nextjs_routes(app):
501515
tail_path = page_path + '{tail:.*}'
502516
app.router.add_get(tail_path, create_nextjs_handler(full_path))
503517

518+
# Some exported apps resolve local assets under an `/_next<page_path>` prefix.
519+
# Mirror those requests back to the same exported directory.
520+
next_prefixed_tail_path = f'/_next{page_path}' + '{tail:.*}'
521+
app.router.add_get(next_prefixed_tail_path, create_nextjs_handler(full_path))
522+
504523

505524
def register_wsgi_routes(app):
506525
'''

‎learning_observer/learning_observer/synthetic_student_data.py‎

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,24 @@
11
'''
2-
Note that current loremipsum in `pip` is not Python 3
3-
compatible. If you are getting b'' in your text, the
4-
patch is at:
5-
6-
`https://github.com/monkeython/loremipsum/issues/10`
2+
Synthetic student payload helpers used for mock-up UX data.
73
'''
84

95
import random
106

117
import numpy
128
import numpy.random
139

14-
import loremipsum
1510
import names
1611

1712
import learning_observer.util as util
13+
from learning_observer.lorem import get_paragraphs
1814

1915

2016
def synthetic_student_data(student_id):
2117
'''
2218
Create fake student data for mock-up UX for one student
2319
'''
2420
name = names.get_first_name()
25-
essay = "\n".join(loremipsum.get_paragraphs(5))
21+
essay = "\n".join(get_paragraphs(5))
2622
return {
2723
'id': student_id,
2824
'name': name,

‎modules/lo_dash_react_components/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ In Learning Observer, we create React components and generate a Python package f
66

77
### Pre-built installation
88

9-
These components take a bit of extra infrastructure to build (mainly `node`). If you just with to use these components, without developing new ones or changing current ones, we suggest installing the pre-built package available in the [LO Assets github repository](https://github.com/ETS-Next-Gen/lo_assets/tree/main/lo_dash_react_components).
9+
These components take a bit of extra infrastructure to build (mainly `node`). If you just with to use these components, without developing new ones or changing current ones, we suggest installing the pre-built package available in the [LO Assets github repository](https://github.com/ArgLab/lo_assets/tree/main/lo_dash_react_components).
1010

1111
### Requirements
1212

‎modules/lo_gpt/lo_gpt/gpt.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,11 @@
1414
import aiohttp
1515
import asyncio
1616
import json
17-
import loremipsum
1817
import os
1918
import random
2019

2120
from learning_observer.log_event import debug_log
21+
from learning_observer.lorem import get_paragraphs
2222
import learning_observer.prestartup
2323
import learning_observer.settings
2424

@@ -151,7 +151,7 @@ def __init__(self, **kwargs):
151151
async def chat_completion(self, prompt, system_prompt):
152152
async with LLM_SEMAPHOR['stub']:
153153
await asyncio.sleep(random.randint(5, 15))
154-
return "\n".join(loremipsum.get_paragraphs(1))
154+
return "\n".join(get_paragraphs(1))
155155

156156

157157
GPT_RESPONDERS = {

0 commit comments

Comments
 (0)