Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ Instructions: Add a subsection under `[Unreleased]` for additions, fixes, change

## [Unreleased]

### Changed

- `pretext view` now reuses the current local server correctly, and starts different local servers for different projects. This allows you to correctly view multiple projects at the same time.

## [2.10.1] - 2024-12-10

Expand Down
93 changes: 44 additions & 49 deletions pretext/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
core,
constants,
plastex,
server,
VERSION,
CORE_COMMIT,
)
Expand Down Expand Up @@ -783,18 +784,23 @@ def view(
"""

# pretext view -s should immediately stop the server and do nothing else.
if utils.cannot_find_project(task="view the output for"):
return
project = Project.parse()

if stop_server:
try:
projectHash = utils.hash_path(project.abspath())
current_server = server.active_server_for_path_hash(projectHash)
log.info("\nStopping server.")
utils.stop_server()
if current_server:
current_server.terminate()
except Exception as e:
log.warning("Failed to stop server.")
log.debug(e, exc_info=True)
finally:
return
if utils.cannot_find_project(task="view the output for"):
return
project = Project.parse()

try:
target = project.get_target(name=target_name, log_info_for_none=not stage)
except AssertionError as e:
Expand Down Expand Up @@ -848,8 +854,26 @@ def view(
webbrowser.open(url)
return
# Start server if there isn't one running already:
used_port = utils.active_server_port()
if restart_server or (port != used_port) or (used_port is None):
projectHash = utils.hash_path(project.abspath())
current_server = server.active_server_for_path_hash(projectHash)
if restart_server and current_server is not None:
log.info(
f"Terminating existing server {current_server.pid} on port {current_server.port}"
)
current_server.terminate()
current_server = None
# Double check that the current server really is active:
if current_server is not None and current_server.isActiveServer():
url_base = utils.url_for_access(access=access, port=current_server.port)
url = url_base + url_path
log.info(f"Server is already available at {url_base}")
if no_launch:
log.info(f"The {target_name} is available at {url}")
else:
log.info(f"Now opening browser for {target_name} at {url}")
webbrowser.open(url)
# otherwise, start a server
else:
log.info(
f"Now preparing local server to preview your project directory `{project.abspath()}`."
)
Expand All @@ -861,50 +885,21 @@ def view(
)
log.info(" personal computer.)")
log.info("")
# First terminate any existing server using this port
if used_port == port:
try:
utils.stop_server(used_port)
except Exception as e:
log.warning("Failed to stop server.")
log.debug(e, exc_info=True)
pass
# Start the new server
server = project.server_process(
access=access,
port=port,
)
server.start()

# Now get the updated port in case we had to pick a new one
actual_port = utils.active_server_port() or port
# set the url
url_base = utils.url_for_access(access=access, port=actual_port)
url = url_base + url_path
log.info(f"Server will soon be available at {url_base}")
if no_launch:
log.info(f"The {target_name} will be available at {url}")
else:
# SECONDS = 2
log.info(f"Opening browser for {target_name} at {url}")
# time.sleep(SECONDS)
webbrowser.open(url)
try:
while server.is_alive():
time.sleep(1)
except KeyboardInterrupt:
log.info("Stopping server.")
server.terminate()
return
else:
url_base = utils.url_for_access(access=access, port=used_port)
url = url_base + url_path
log.info(f"Server is already available at {url_base}")
if no_launch:
log.info(f"The {target_name} is available at {url}")
else:
log.info(f"Now opening browser for {target_name} at {url}")
webbrowser.open(url)
def callback(actual_port: int) -> None:
url_base = utils.url_for_access(access=access, port=actual_port)
url = url_base + url_path
log.info(f"Server will soon be available at {url_base}")
if no_launch:
log.info(f"The {target_name} will be available at {url}")
else:
# SECONDS = 2
log.info(f"Opening browser for {target_name} at {url}")
# time.sleep(SECONDS)
webbrowser.open(url)

log.info("starting server ...")
server.start_server(project.abspath(), access, port, callback)


# pretext deploy
Expand Down
201 changes: 201 additions & 0 deletions pretext/server.py
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting that this didn't appear to be a problem when I was testing locally. I could have sworn I had tested with more than one entry.
Would this be better done by simply adding the newline in the toFileLine function instead?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I like that better actually. I'll make that change.

Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
from __future__ import annotations
from dataclasses import dataclass
from http.server import SimpleHTTPRequestHandler
import logging
import os
from pathlib import Path
import socketserver
import typing as t
import psutil

from pretext.utils import hash_path, home_path

# Get access to logger
log = logging.getLogger("ptxlogger")


@dataclass
class RunningServerInfo:
"""A simple dataclass to hold the information in the running servers file."""

pathHash: str
pid: int
port: int
binding: str

@staticmethod
def fromFileLine(line: str) -> RunningServerInfo:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that methods in Python should be snake_case: https://peps.python.org/pep-0008/#method-names-and-instance-variables

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's funny that the logging module doesn't follow that convention. I've definitely been bad at following the convention, as my mind really doesn't like it at all.
Would it perhaps be possible to add this as something reported by the styling tools? https://github.com/PyCQA/pep8-naming

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think Java mixes snake_case and camelCase, if I recall? I know it's a convention elsewhere, but I try to stick with pep8 for Python, even if Python itself violates it in some core libraries...

And yeah, really this should be caught by our linter.

(pathHash, pid, port, binding) = line.split()
return RunningServerInfo(
pathHash=pathHash, pid=int(pid), port=int(port), binding=binding
)

def toFileLine(self) -> str:
return f"{self.pathHash} {self.pid} {self.port} {self.binding}\n"

def isActiveServer(self) -> bool:
"""Returns whether the server represented by this object is active on the provided port"""
p = psutil.Process(self.pid)
if not p.is_running():
log.info(f"Found entry no longer running {p.pid}")
return False
if p.status == psutil.STATUS_ZOMBIE:
log.info(f"Found zombie process {p.pid}")
return False
for _, _, _, laddr, _, _ in p.net_connections("all"):
if laddr.port == self.port:
log.info(f"Found server at {self.url()}")
return True
log.info(
f"Found process {self.pid} no longer listening on specified port {self.port}"
)
return False

def terminate(self) -> None:
"""Attempt to terminate the process described by this info."""
try:
log.info(f"Terminating {self.pid}")
psutil.Process(self.pid).terminate()
remove_server_entry(self.pathHash)
except Exception as e:
log.info(f"Terminate failed for {self.pid}.")
log.exception(e, exc_info=True)

def url(self) -> str:
return f"{self.binding}:{self.port}"


def get_running_servers() -> t.List[RunningServerInfo]:
"""
Processes the ~/.ptx/running_servers file to retrieve a list
of any possibly current running servers.
"""
if not home_path().exists():
return []
try:
runningServersFile = home_path() / "running_servers"
if not runningServersFile.is_file():
return []
with open(runningServersFile, "r") as f:
return [RunningServerInfo.fromFileLine(line) for line in f.readlines()]
except IOError as e:
log.info("Unable to open running servers file.")
log.exception(e, exc_info=True)
return []


def save_running_servers(runningServers: t.List[RunningServerInfo]) -> None:
"""
Overwrites the ~/.ptx/running_servers file to store
the new list of running servers.
"""
# Ensure home path exists
os.makedirs(home_path(), exist_ok=True)
try:
runningServersFile = home_path() / "running_servers"
with open(runningServersFile, "w") as f:
# Write each server info to a new line
f.writelines([info.toFileLine() for info in runningServers])
except IOError as e:
log.info("Unable to write running servers file.")
log.exception(e, exc_info=True)


def add_server_entry(pathHash: str, pid: int, port: int, binding: str) -> None:
"""Add a new server entry to ~/.ptx/running_servers.

This function does not attempt to ensure that an active server doesn't already exist.
"""
PURGE_LIMIT = 10 # If more servers active, try to clean up
runningServers = get_running_servers()
newEntry = RunningServerInfo(pathHash=pathHash, pid=pid, port=port, binding=binding)
runningServers.append(newEntry)
if len(runningServers) >= PURGE_LIMIT:
log.info(f"There are {PURGE_LIMIT} or more servers on file. Cleaning up ...")
runningServers = list(stop_inactive_servers(runningServers))
save_running_servers(runningServers)
log.info(f"Added server entry {newEntry.toFileLine()}")


def remove_server_entry(pathHash: str) -> None:
remainingServers = [
info for info in get_running_servers() if info.pathHash != pathHash
]
save_running_servers(remainingServers)


def stop_inactive_servers(
servers: t.List[RunningServerInfo],
) -> t.Iterator[RunningServerInfo]:
"""Stops any inactive servers and yields the active ones."""
for server in servers:
if server.isActiveServer():
yield server
else:
server.terminate()


def active_server_for_path_hash(pathHash: str) -> t.Optional[RunningServerInfo]:
return next(
(info for info in get_running_servers() if info.pathHash == pathHash),
None,
)


# boilerplate to prevent overzealous caching by preview server, and
# avoid port issues
def binding_for_access(access: t.Literal["public", "private"] = "private") -> str:
if access == "private":
return "localhost"
return "0.0.0.0"


def start_server(
base_dir: Path,
access: t.Literal["public", "private"] = "private",
port: int = 8128,
callback: t.Callable[[int], None] | None = None,
) -> None:
log.info("setting up ...")
pathHash = hash_path(base_dir)
pid = os.getpid()
binding = binding_for_access(access)
log.info("values set...")

# Previously we defined a custom handler to prevent caching, but we don't need to do that anymore. It was causing issues with the _static js/css files inside codespaces for an unknown reason. Might bring this back in the future.
# 2024-04-05: try using this again to let Firefox work
class RequestHandler(SimpleHTTPRequestHandler):
def __init__(self, *args: t.Any, **kwargs: t.Any):
super().__init__(*args, directory=base_dir.as_posix(), **kwargs)

"""HTTP request handler with no caching"""

def end_headers(self) -> None:
self.send_my_headers()
SimpleHTTPRequestHandler.end_headers(self)

def send_my_headers(self) -> None:
self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
self.send_header("Pragma", "no-cache")
self.send_header("Expires", "0")

class TCPServer(socketserver.TCPServer):
allow_reuse_address = True

while True:
try:
with TCPServer((binding, port), RequestHandler) as httpd:
log.info("adding server entry")
add_server_entry(pathHash, pid, port, binding)
log.info("Starting the server")
if callback is not None:
callback(port)
httpd.serve_forever()
except OSError:
log.warning(f"Port {port} could not be used.")
port += 1
log.warning(f"Trying port {port} instead.\n")
except KeyboardInterrupt:
log.info("Stopping server.")
remove_server_entry(pathHash)
return
Loading