mirror of
https://github.com/rio-labs/rio.git
synced 2026-01-06 05:09:43 -06:00
completely reworked upload area
This commit is contained in:
@@ -1,17 +1,63 @@
|
||||
import { applySwitcheroo } from '../designApplication';
|
||||
import { applyIcon, applySwitcheroo } from '../designApplication';
|
||||
import { ComponentBase, ComponentState } from './componentBase';
|
||||
import { RippleEffect } from '../rippleEffect';
|
||||
import { markEventAsHandled } from '../eventHandling';
|
||||
|
||||
/// Maps MIME types to what sort of file they represent
|
||||
const EXTENSION_TO_CATEGORY = {
|
||||
bmp: 'picture',
|
||||
csv: 'data',
|
||||
doc: 'document',
|
||||
docx: 'document',
|
||||
gif: 'picture',
|
||||
h5: 'data',
|
||||
hdf5: 'data',
|
||||
ico: 'picture',
|
||||
ini: 'data',
|
||||
jpg: 'picture',
|
||||
json: 'data',
|
||||
jxl: 'picture',
|
||||
md: 'document',
|
||||
mdb: 'data',
|
||||
ods: 'data',
|
||||
odt: 'document',
|
||||
parquet: 'data',
|
||||
pdf: 'document',
|
||||
pickle: 'data',
|
||||
png: 'picture',
|
||||
pptx: 'document',
|
||||
svg: 'picture',
|
||||
toml: 'data',
|
||||
tsv: 'data',
|
||||
txt: 'document',
|
||||
webp: 'picture',
|
||||
xlsx: 'data',
|
||||
};
|
||||
|
||||
/// Maps types of files to
|
||||
///
|
||||
/// - A human-readable name
|
||||
/// - An icon
|
||||
const CATEGORY_TO_METADATA = {
|
||||
document: ['Documents', 'material/description'],
|
||||
picture: ['Pictures', 'material/landscape:fill'],
|
||||
data: ['Data files', 'material/pie_chart'],
|
||||
};
|
||||
|
||||
type UploadAreaState = ComponentState & {
|
||||
_type_: 'UploadArea-builtin';
|
||||
content?: string;
|
||||
file_types?: string[];
|
||||
};
|
||||
|
||||
export class UploadAreaComponent extends ComponentBase {
|
||||
state: Required<UploadAreaState>;
|
||||
|
||||
private fileInput: HTMLInputElement;
|
||||
private iconElement: HTMLElement;
|
||||
private childContainer: HTMLElement;
|
||||
private titleElement: HTMLElement;
|
||||
private fileTypesElement: HTMLElement;
|
||||
private progressElement: HTMLElement;
|
||||
|
||||
private rippleInstance: RippleEffect;
|
||||
|
||||
@@ -20,13 +66,32 @@ export class UploadAreaComponent extends ComponentBase {
|
||||
let element = document.createElement('div');
|
||||
element.classList.add('rio-upload-area');
|
||||
|
||||
// Create the icon element but don't add it
|
||||
this.iconElement = document.createElement('div');
|
||||
this.iconElement.classList.add('rio-upload-area-icon');
|
||||
|
||||
// Create the child container
|
||||
this.childContainer = document.createElement('div');
|
||||
this.childContainer.classList.add('rio-upload-area-child-container');
|
||||
element.appendChild(this.childContainer);
|
||||
|
||||
// Create the title element, but don't add it
|
||||
this.titleElement = document.createElement('div');
|
||||
this.titleElement.classList.add('rio-upload-area-title');
|
||||
|
||||
// Same for the file types element
|
||||
this.fileTypesElement = document.createElement('div');
|
||||
this.fileTypesElement.classList.add('rio-upload-area-file-types');
|
||||
|
||||
// Create the progress element
|
||||
this.progressElement = document.createElement('div');
|
||||
this.progressElement.classList.add('rio-upload-area-progress-bar');
|
||||
element.appendChild(this.progressElement);
|
||||
|
||||
// A hidden file input
|
||||
this.fileInput = document.createElement('input');
|
||||
this.fileInput.type = 'file';
|
||||
this.fileInput.multiple = true;
|
||||
element.appendChild(this.fileInput);
|
||||
|
||||
// Add a material ripple effect
|
||||
@@ -34,6 +99,9 @@ export class UploadAreaComponent extends ComponentBase {
|
||||
triggerOnPress: false,
|
||||
});
|
||||
|
||||
// Populate the child container with the default content
|
||||
this.createDefaultContent();
|
||||
|
||||
// Connect event handlers
|
||||
//
|
||||
// Highlight drop area when dragging files over it
|
||||
@@ -95,8 +163,8 @@ export class UploadAreaComponent extends ComponentBase {
|
||||
|
||||
// Handle files selected from the file input
|
||||
this.fileInput.addEventListener('change', (e) => {
|
||||
// const files = e.target.files;
|
||||
// this.uploadFiles(files);
|
||||
const files = e.target.files;
|
||||
this.uploadFiles(files);
|
||||
});
|
||||
|
||||
return element;
|
||||
@@ -107,12 +175,159 @@ export class UploadAreaComponent extends ComponentBase {
|
||||
latentComponents: Set<ComponentBase>
|
||||
): void {
|
||||
super.updateElement(deltaState, latentComponents);
|
||||
|
||||
// Title
|
||||
if (deltaState.content !== undefined) {
|
||||
this.titleElement.textContent = deltaState.content;
|
||||
}
|
||||
|
||||
// File types
|
||||
if (deltaState.file_types !== undefined) {
|
||||
// Update the UI
|
||||
this.updateFileTypes(deltaState.file_types);
|
||||
|
||||
// Update the file input
|
||||
this.fileInput.type = 'file';
|
||||
this.fileInput.accept = deltaState.file_types
|
||||
.map((x) => `.${x}`)
|
||||
.join(',');
|
||||
|
||||
this.fileInput.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
uploadFiles(files) {
|
||||
[...files].forEach((file) => {
|
||||
console.log(file.name);
|
||||
// Add any further file handling logic here
|
||||
});
|
||||
/// Updates the text & icon for the file types
|
||||
updateFileTypes(fileTypes: string[] | null): void {
|
||||
// Start off generic
|
||||
let fileTypesText: string = 'All files supported';
|
||||
let icon: string = 'material/upload';
|
||||
|
||||
// Are there a nicer text & icon to display?
|
||||
if (fileTypes !== null) {
|
||||
// What sort of files types are we dealing with?
|
||||
let fileCategories = new Set<string>();
|
||||
|
||||
for (const fileType of fileTypes) {
|
||||
if (fileType in EXTENSION_TO_CATEGORY) {
|
||||
fileCategories.add(EXTENSION_TO_CATEGORY[fileType]);
|
||||
} else {
|
||||
fileCategories.clear();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Is there a single type?
|
||||
if (fileCategories.size === 1) {
|
||||
const category = fileCategories.values().next().value;
|
||||
const [newText, newIcon] = CATEGORY_TO_METADATA[category];
|
||||
|
||||
if (fileTypes.length === 1) {
|
||||
fileTypesText = `${fileTypes[0].toUpperCase()} ${newText}`;
|
||||
} else {
|
||||
let extensionText = fileTypes
|
||||
.map((x) => `*.${x}`)
|
||||
.join(', ');
|
||||
fileTypesText = `${newText} (${extensionText})`;
|
||||
}
|
||||
|
||||
icon = newIcon;
|
||||
}
|
||||
// Nope, but we can list the extensions
|
||||
else {
|
||||
fileTypesText = fileTypes.map((x) => `*.${x}`).join(', ');
|
||||
}
|
||||
}
|
||||
|
||||
// Apply the values
|
||||
applyIcon(this.iconElement, icon, 'currentColor');
|
||||
this.fileTypesElement.textContent = fileTypesText;
|
||||
}
|
||||
|
||||
/// Populates the child container with the default content
|
||||
createDefaultContent(): void {
|
||||
// Icon
|
||||
this.childContainer.appendChild(this.iconElement);
|
||||
|
||||
// Column for the title and file types
|
||||
let column = document.createElement('div');
|
||||
column.classList.add('rio-upload-area-column');
|
||||
this.childContainer.appendChild(column);
|
||||
|
||||
// Title
|
||||
column.appendChild(this.titleElement);
|
||||
|
||||
// File types
|
||||
column.appendChild(this.fileTypesElement);
|
||||
|
||||
// Button
|
||||
//
|
||||
// The structure below is more complicated than strictly necessary. This
|
||||
// is done to emulate the HTML of a regular `rio.Button`, so the
|
||||
// existing button styles can be used.
|
||||
//
|
||||
// Note that the button needs to event handler at all. The file input
|
||||
// already handles click events as intended. The button merely serves
|
||||
// as visual indicator that the area is clickable.
|
||||
let buttonOuter = document.createElement('div');
|
||||
buttonOuter.classList.add(
|
||||
'rio-upload-area-button',
|
||||
'rio-button',
|
||||
'rio-shape-rounded'
|
||||
);
|
||||
this.childContainer.appendChild(buttonOuter);
|
||||
|
||||
let buttonInner = document.createElement('div');
|
||||
buttonInner.classList.add(
|
||||
'rio-switcheroo-bump',
|
||||
'rio-buttonstyle-major'
|
||||
);
|
||||
buttonOuter.appendChild(buttonInner);
|
||||
buttonInner.textContent = 'Browse';
|
||||
}
|
||||
|
||||
uploadFiles(files: FileList): void {
|
||||
// Build a `FormData` object containing the files
|
||||
const data = new FormData();
|
||||
|
||||
let ii = 0;
|
||||
for (const file of files || []) {
|
||||
ii += 1;
|
||||
data.append('file_names', file.name);
|
||||
data.append('file_types', file.type);
|
||||
data.append('file_sizes', file.size.toString());
|
||||
data.append('file_streams', file, file.name);
|
||||
}
|
||||
|
||||
// FastAPI has trouble parsing empty form data. Append a dummy value so
|
||||
// it's never empty
|
||||
data.append('dummy', 'dummy');
|
||||
|
||||
// Upload the files
|
||||
const xhr = new XMLHttpRequest();
|
||||
const url = `${globalThis.RIO_BASE_URL}rio/upload-to-component/${globalThis.SESSION_TOKEN}/${this.id}`;
|
||||
|
||||
xhr.open('PUT', url, true);
|
||||
|
||||
xhr.upload.onprogress = (event) => {
|
||||
if (event.lengthComputable) {
|
||||
const progress = event.loaded / event.total;
|
||||
|
||||
this.progressElement.style.opacity = '1';
|
||||
this.progressElement.style.setProperty(
|
||||
'--progress',
|
||||
`${progress * 100}%`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onload = () => {
|
||||
this.progressElement.style.opacity = '0';
|
||||
};
|
||||
|
||||
xhr.onerror = () => {
|
||||
this.progressElement.style.opacity = '0';
|
||||
};
|
||||
|
||||
xhr.send(data);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ export function requestFileUpload(message: any): void {
|
||||
input.multiple = message.multiple;
|
||||
|
||||
if (message.fileExtensions !== null) {
|
||||
input.accept = message.fileExtensions.join(',');
|
||||
input.accept = message.fileExtensions.map((x) => `.${x}`).join(',');
|
||||
}
|
||||
|
||||
input.style.display = 'none';
|
||||
|
||||
@@ -3798,23 +3798,83 @@ html.picking-component * {
|
||||
|
||||
position: relative;
|
||||
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
justify-content: stretch;
|
||||
|
||||
// To round the progress bar
|
||||
overflow: hidden;
|
||||
|
||||
color: var(--rio-local-fg);
|
||||
background-color: var(--rio-local-bg-variant);
|
||||
|
||||
border-radius: var(--rio-global-corner-radius-medium);
|
||||
|
||||
@include single-container();
|
||||
}
|
||||
|
||||
.rio-upload-area:not(.rio-upload-area-file-hover):hover {
|
||||
background-color: var(--rio-local-bg-active);
|
||||
}
|
||||
|
||||
.rio-upload-area-child-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.rio-upload-area-icon {
|
||||
width: 3.5rem;
|
||||
height: 3.5rem;
|
||||
}
|
||||
|
||||
.rio-upload-area-column {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.rio-upload-area-title {
|
||||
}
|
||||
|
||||
.rio-upload-area-file-types {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.rio-upload-area-button > div {
|
||||
color: var(--rio-local-text-color);
|
||||
padding: 0.5rem 1rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.rio-upload-area-progress-bar {
|
||||
flex-grow: 0;
|
||||
|
||||
height: 0.2rem;
|
||||
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.rio-upload-area-progress-bar::after {
|
||||
content: '';
|
||||
display: block;
|
||||
position: relative;
|
||||
width: var(--progress);
|
||||
height: 100%;
|
||||
background: var(--rio-local-level-2-bg);
|
||||
|
||||
// This would be nice, but also causes the progress bar to animate when
|
||||
// receding back to 0.
|
||||
//
|
||||
// transition: width 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.rio-upload-area-file-hover {
|
||||
cursor: copy;
|
||||
color: var(--rio-local-level-2-bg);
|
||||
}
|
||||
|
||||
.rio-upload-area::before {
|
||||
@@ -3831,7 +3891,7 @@ html.picking-component * {
|
||||
background: radial-gradient(
|
||||
circle at var(--x) var(--y),
|
||||
var(--rio-local-level-2-bg),
|
||||
transparent 30rem
|
||||
var(--rio-local-bg-active) 30rem
|
||||
);
|
||||
|
||||
opacity: 0;
|
||||
|
||||
@@ -150,9 +150,9 @@ class AbstractAppServer(abc.ABC):
|
||||
self,
|
||||
session: rio.Session,
|
||||
*,
|
||||
file_extensions: Iterable[str] | None = None,
|
||||
file_types: Iterable[str] | None = None,
|
||||
multiple: bool = False,
|
||||
) -> utils.FileInfo | tuple[utils.FileInfo, ...]:
|
||||
) -> utils.FileInfo | list[utils.FileInfo]:
|
||||
raise NotImplementedError
|
||||
|
||||
@abc.abstractmethod
|
||||
|
||||
@@ -829,6 +829,9 @@ Sitemap: {base_url / "/rio/sitemap"}
|
||||
file_streams,
|
||||
)
|
||||
|
||||
for file in files:
|
||||
print(f"DEBUG: Got file: {file.name} ({file.size_in_bytes} bytes)")
|
||||
|
||||
# Let the component handle the files
|
||||
await handler(files)
|
||||
|
||||
@@ -841,9 +844,9 @@ Sitemap: {base_url / "/rio/sitemap"}
|
||||
self,
|
||||
session: rio.Session,
|
||||
*,
|
||||
file_extensions: Iterable[str] | None = None,
|
||||
file_types: Iterable[str] | None = None,
|
||||
multiple: bool = False,
|
||||
) -> utils.FileInfo | tuple[utils.FileInfo, ...]:
|
||||
) -> utils.FileInfo | list[utils.FileInfo]:
|
||||
# Create a secret id and register the file upload with the app server
|
||||
upload_id = secrets.token_urlsafe()
|
||||
future = asyncio.Future[list[utils.FileInfo]]()
|
||||
@@ -851,10 +854,9 @@ Sitemap: {base_url / "/rio/sitemap"}
|
||||
self._pending_file_uploads[upload_id] = future
|
||||
|
||||
# Allow the user to specify both `jpg` and `.jpg`
|
||||
if file_extensions is not None:
|
||||
file_extensions = [
|
||||
ext if ext.startswith(".") else f".{ext}"
|
||||
for ext in file_extensions
|
||||
if file_types is not None:
|
||||
file_types = [
|
||||
ext if ext.startswith(".") else f".{ext}" for ext in file_types
|
||||
]
|
||||
|
||||
# Tell the frontend to upload a file
|
||||
@@ -862,7 +864,7 @@ Sitemap: {base_url / "/rio/sitemap"}
|
||||
|
||||
await session._request_file_upload(
|
||||
upload_url=str(base_url / f"rio/upload/{upload_id}"),
|
||||
file_extensions=file_extensions,
|
||||
file_types=file_types,
|
||||
multiple=multiple,
|
||||
)
|
||||
|
||||
@@ -882,7 +884,7 @@ Sitemap: {base_url / "/rio/sitemap"}
|
||||
|
||||
# Return the file info
|
||||
if multiple:
|
||||
return tuple(files) # type: ignore
|
||||
return files
|
||||
else:
|
||||
return files[0]
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ class TestingServer(AbstractAppServer):
|
||||
self,
|
||||
session: rio.Session,
|
||||
*,
|
||||
file_extensions: Iterable[str] | None = None,
|
||||
file_types: Iterable[str] | None = None,
|
||||
multiple: bool = False,
|
||||
) -> utils.FileInfo | tuple[utils.FileInfo, ...]:
|
||||
raise NotImplementedError
|
||||
|
||||
@@ -3,8 +3,11 @@ from __future__ import annotations
|
||||
from dataclasses import KW_ONLY
|
||||
from typing import * # type: ignore
|
||||
|
||||
from uniserde import JsonDoc
|
||||
|
||||
import rio
|
||||
|
||||
from .. import utils
|
||||
from .fundamental_component import FundamentalComponent
|
||||
|
||||
__all__ = [
|
||||
@@ -14,10 +17,29 @@ __all__ = [
|
||||
|
||||
@final
|
||||
class UploadArea(FundamentalComponent):
|
||||
content: str = "Drag & drop files here"
|
||||
_: KW_ONLY
|
||||
file_types: list[str] | None = None
|
||||
on_file_upload: rio.EventHandler[list[rio.FileInfo]] = None
|
||||
|
||||
async def _on_file_upload(self, files: list[rio.FileInfo]) -> None:
|
||||
print(f"Files uploaded: {files}")
|
||||
def _custom_serialize_(self) -> JsonDoc:
|
||||
if self.file_types is None:
|
||||
return {}
|
||||
|
||||
return {
|
||||
"file_types": list(
|
||||
{
|
||||
utils.normalize_file_type(file_type)
|
||||
for file_type in self.file_types
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
async def _on_file_upload_(self, files: list[rio.FileInfo]) -> None:
|
||||
print(f"Files uploaded:")
|
||||
|
||||
for file in files:
|
||||
print(f" {file.name} ({file.size_in_bytes} bytes)")
|
||||
|
||||
|
||||
UploadArea._unique_id_ = "UploadArea-builtin"
|
||||
|
||||
@@ -13,7 +13,7 @@ if TYPE_CHECKING:
|
||||
__all__ = [
|
||||
"deprecated",
|
||||
"_remap_kwargs",
|
||||
"component_kwarg_renamed",
|
||||
"function_kwarg_renamed",
|
||||
"warn",
|
||||
]
|
||||
|
||||
@@ -182,3 +182,37 @@ def _remap_kwargs(
|
||||
since=since,
|
||||
message=f"The {old_name!r} parameter of rio.{func_name} is deprecated. Please use `{new_name!r}` instead.",
|
||||
)
|
||||
|
||||
|
||||
def function_kwarg_renamed(
|
||||
since: str,
|
||||
old_name: str,
|
||||
new_name: str,
|
||||
) -> Callable[[F], F]:
|
||||
"""
|
||||
This decorator helps with renaming a keyword argument of a function, NOT a
|
||||
component.
|
||||
"""
|
||||
|
||||
def decorator(old_function: F) -> F:
|
||||
@functools.wraps(old_function)
|
||||
def new_function(*args: tuple, **kwargs: dict):
|
||||
# Remap the old parameter to the new one
|
||||
try:
|
||||
kwargs[new_name] = kwargs.pop(old_name)
|
||||
except KeyError:
|
||||
pass
|
||||
else:
|
||||
warn(
|
||||
since=since,
|
||||
message=f"The `{old_name}` parameter of `{old_function.__name__}` is deprecated. Please use `{new_name}` instead.",
|
||||
stacklevel=3,
|
||||
)
|
||||
|
||||
# Delegate to the original function
|
||||
return old_function(*args, **kwargs)
|
||||
|
||||
# Return the modified function
|
||||
return new_function
|
||||
|
||||
return decorator
|
||||
|
||||
@@ -29,6 +29,7 @@ from . import (
|
||||
app_server,
|
||||
assets,
|
||||
data_models,
|
||||
deprecations,
|
||||
errors,
|
||||
fills,
|
||||
global_state,
|
||||
@@ -2026,7 +2027,7 @@ window.history.{method}(null, "", {json.dumps(active_page_url.path)})
|
||||
async def file_chooser(
|
||||
self,
|
||||
*,
|
||||
file_extensions: Iterable[str] | None = None,
|
||||
file_types: Iterable[str] | None = None,
|
||||
multiple: Literal[False] = False,
|
||||
) -> utils.FileInfo: ...
|
||||
|
||||
@@ -2034,16 +2035,21 @@ window.history.{method}(null, "", {json.dumps(active_page_url.path)})
|
||||
async def file_chooser(
|
||||
self,
|
||||
*,
|
||||
file_extensions: Iterable[str] | None = None,
|
||||
file_types: Iterable[str] | None = None,
|
||||
multiple: Literal[True],
|
||||
) -> tuple[utils.FileInfo, ...]: ...
|
||||
) -> list[utils.FileInfo]: ...
|
||||
|
||||
@deprecations.function_kwarg_renamed(
|
||||
since="0.9.3",
|
||||
old_name="file_extension",
|
||||
new_name="file_types",
|
||||
)
|
||||
async def file_chooser(
|
||||
self,
|
||||
*,
|
||||
file_extensions: Iterable[str] | None = None,
|
||||
file_types: Iterable[str] | None = None,
|
||||
multiple: bool = False,
|
||||
) -> utils.FileInfo | tuple[utils.FileInfo, ...]:
|
||||
) -> utils.FileInfo | list[utils.FileInfo]:
|
||||
"""
|
||||
Open a file chooser dialog.
|
||||
|
||||
@@ -2055,9 +2061,10 @@ window.history.{method}(null, "", {json.dumps(active_page_url.path)})
|
||||
|
||||
## Parameters
|
||||
|
||||
`file_extensions`: A list of file extensions which the user is allowed
|
||||
to select. Defaults to `None`, which means that the user may
|
||||
select any file.
|
||||
`file_types`: A list of file extensions which the user is allowed
|
||||
to select. Defaults to `None`, which means that the user may select
|
||||
any file. Values can be passed as file extensions, ('pdf',
|
||||
'.pdf', '*.pdf' are all accepted) or MIME types (e.g. 'application/pdf').
|
||||
|
||||
`multiple`: Whether the user should pick a single file, or multiple.
|
||||
|
||||
@@ -2066,9 +2073,15 @@ window.history.{method}(null, "", {json.dumps(active_page_url.path)})
|
||||
|
||||
`NoFileSelectedError`: If the user did not select a file.
|
||||
"""
|
||||
# Normalize the file types
|
||||
if file_types is not None:
|
||||
file_types = {
|
||||
utils.normalize_file_type(file_type) for file_type in file_types
|
||||
}
|
||||
|
||||
return await self._app_server.file_chooser(
|
||||
self,
|
||||
file_extensions=file_extensions,
|
||||
file_types=file_types,
|
||||
multiple=multiple,
|
||||
)
|
||||
|
||||
@@ -2970,11 +2983,14 @@ a.remove();
|
||||
async def _request_file_upload(
|
||||
self,
|
||||
upload_url: str,
|
||||
file_extensions: list[str] | None,
|
||||
file_types: list[str] | None,
|
||||
multiple: bool,
|
||||
) -> None:
|
||||
"""
|
||||
Tell the client to upload a file to the server.
|
||||
|
||||
If `file_types` is provided, the strings should be file extensions,
|
||||
without any leading dots. E.g. `["pdf", "png"]`.
|
||||
"""
|
||||
raise NotImplementedError # pragma: no cover
|
||||
|
||||
|
||||
58
rio/utils.py
58
rio/utils.py
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
@@ -423,3 +424,60 @@ def load_module_from_path(file_path: Path, *, module_name: str | None = None):
|
||||
|
||||
def is_python_script(path: Path) -> bool:
|
||||
return path.suffix in (".py", ".pyc", ".pyd", ".pyo", ".pyw")
|
||||
|
||||
|
||||
def normalize_file_type(file_type: str) -> str:
|
||||
"""
|
||||
Converts different file type formats into a common one.
|
||||
|
||||
This function takes various formats of file types, such as file extensions
|
||||
(e.g., ".pdf", "PDF", "*.pdf") or MIME types (e.g., "application/pdf"), and
|
||||
converts them into a standardized file extension. The result is always
|
||||
lowercase and without any leading dots or wildcard characters.
|
||||
|
||||
This is best-effort. If the input type is invalid or unknown, the cleaned
|
||||
input may not be accurate.
|
||||
|
||||
Examples:
|
||||
>>> standardize_file_type("pdf")
|
||||
'pdf'
|
||||
>>> standardize_file_type(".PDF")
|
||||
'pdf'
|
||||
>>> standardize_file_type("*.pdf")
|
||||
'pdf'
|
||||
>>> standardize_file_type("application/pdf")
|
||||
'pdf'
|
||||
"""
|
||||
# Normalize the input string
|
||||
file_type = file_type.lower().strip()
|
||||
|
||||
# If this is a MIME type, guess the extension
|
||||
if "/" in file_type:
|
||||
guessed_type = mimetypes.guess_extension(file_type, strict=False)
|
||||
|
||||
if guessed_type is None:
|
||||
file_type = file_type.rsplit("/", 1)[-1]
|
||||
else:
|
||||
file_type = guessed_type.lstrip(".")
|
||||
|
||||
# If it isn't a MIME type, convert it to one anyway. Some file types have
|
||||
# multiple commonly used extensions. This will always map them to the same
|
||||
# one. For example "jpeg" and "jpg" are both mapped to "jpg".
|
||||
else:
|
||||
guessed_type, _ = mimetypes.guess_type(
|
||||
f"file.{file_type}", strict=False
|
||||
)
|
||||
|
||||
if guessed_type is None:
|
||||
file_type = file_type.lstrip(".*")
|
||||
else:
|
||||
guessed_type = mimetypes.guess_extension(
|
||||
guessed_type,
|
||||
strict=False,
|
||||
)
|
||||
|
||||
assert guessed_type is not None
|
||||
file_type = guessed_type.lstrip(".")
|
||||
|
||||
# Done
|
||||
return file_type
|
||||
|
||||
30
tests/test_utils.py
Normal file
30
tests/test_utils.py
Normal file
@@ -0,0 +1,30 @@
|
||||
import pytest
|
||||
|
||||
import rio
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"input_type,output_type",
|
||||
[
|
||||
# Simple cases
|
||||
("pdf", "pdf"),
|
||||
(".Pdf", "pdf"),
|
||||
(".PDF", "pdf"),
|
||||
("*pdf", "pdf"),
|
||||
("*.Pdf", "pdf"),
|
||||
("*.PDF", "pdf"),
|
||||
("application/pdf", "pdf"),
|
||||
# Make the results are standardized
|
||||
(".jpg", "jpg"),
|
||||
(".jpeg", "jpg"),
|
||||
# Invalid MIME types
|
||||
("not/a/real/type", "type"),
|
||||
("////Type", "type"),
|
||||
],
|
||||
)
|
||||
def test_standardize_file_types(
|
||||
input_type: str,
|
||||
output_type: str,
|
||||
) -> None:
|
||||
cleaned_type = rio.utils.normalize_file_type(input_type)
|
||||
assert cleaned_type == output_type
|
||||
Reference in New Issue
Block a user