diff --git a/hyperglass/ui/.eslintignore b/hyperglass/ui/.eslintignore
new file mode 100644
index 0000000..4c7afc3
--- /dev/null
+++ b/hyperglass/ui/.eslintignore
@@ -0,0 +1,4 @@
+node_modules
+dist
+.next/
+favicon-formats.ts
diff --git a/hyperglass/ui/.gitignore b/hyperglass/ui/.gitignore
index 167c7e7..4a9306a 100644
--- a/hyperglass/ui/.gitignore
+++ b/hyperglass/ui/.gitignore
@@ -1,4 +1,5 @@
.DS_Store
+.env*
*.tsbuildinfo
# dev/test files
TODO.txt
diff --git a/hyperglass/ui/.prettierignore b/hyperglass/ui/.prettierignore
index b1e1b18..6b48447 100644
--- a/hyperglass/ui/.prettierignore
+++ b/hyperglass/ui/.prettierignore
@@ -5,4 +5,5 @@ yarn.lock
package-lock.json
.eslintrc
tsconfig.json
-.next/
\ No newline at end of file
+.next/
+favicon-formats.ts
diff --git a/hyperglass/ui/components/favicon.tsx b/hyperglass/ui/components/favicon.tsx
new file mode 100644
index 0000000..c743934
--- /dev/null
+++ b/hyperglass/ui/components/favicon.tsx
@@ -0,0 +1,11 @@
+import type { Favicon as FaviconProps } from '~/types';
+
+/**
+ * Render a `` element to reference a server-side favicon.
+ */
+export const Favicon = (props: FaviconProps): JSX.Element => {
+ const { image_format, dimensions, prefix, rel } = props;
+ const [w, h] = dimensions;
+ const src = `/images/favicons/${prefix}-${w}x${h}.${image_format}`;
+ return ;
+};
diff --git a/hyperglass/ui/components/index.ts b/hyperglass/ui/components/index.ts
index 4ae1917..ee7d018 100644
--- a/hyperglass/ui/components/index.ts
+++ b/hyperglass/ui/components/index.ts
@@ -2,6 +2,7 @@ export * from './card';
export * from './codeBlock';
export * from './countdown';
export * from './debugger';
+export * from './favicon';
export * from './footer';
export * from './form';
export * from './greeting';
diff --git a/hyperglass/ui/components/meta.tsx b/hyperglass/ui/components/meta.tsx
index 844f6b1..6f9c61e 100644
--- a/hyperglass/ui/components/meta.tsx
+++ b/hyperglass/ui/components/meta.tsx
@@ -4,7 +4,7 @@ import { useTheme } from '@chakra-ui/react';
import { useConfig } from '~/context';
import { googleFontUrl } from '~/util';
-export const Meta: React.FC = () => {
+export const Meta = (): JSX.Element => {
const config = useConfig();
const { fonts } = useTheme();
const [location, setLocation] = useState('/');
@@ -12,22 +12,6 @@ export const Meta: React.FC = () => {
const {
siteTitle: title = 'hyperglass',
siteDescription: description = 'Network Looking Glass',
- siteKeywords: keywords = [
- 'hyperglass',
- 'looking glass',
- 'lg',
- 'peer',
- 'peering',
- 'ipv4',
- 'ipv6',
- 'transit',
- 'community',
- 'communities',
- 'bgp',
- 'routing',
- 'network',
- 'isp',
- ],
} = useConfig();
const siteName = `${title} - ${description}`;
@@ -42,8 +26,7 @@ export const Meta: React.FC = () => {
return (
- {title}
-
+ {title}
@@ -52,8 +35,7 @@ export const Meta: React.FC = () => {
-
-
+
);
};
diff --git a/hyperglass/ui/favicon-formats.ts b/hyperglass/ui/favicon-formats.ts
new file mode 100644
index 0000000..048d683
--- /dev/null
+++ b/hyperglass/ui/favicon-formats.ts
@@ -0,0 +1 @@
+import type { Favicon } from '~/types';export default [{"dimensions": [64, 64], "image_format": "ico", "prefix": "favicon", "rel": null}, {"dimensions": [16, 16], "image_format": "png", "prefix": "favicon", "rel": "icon"}, {"dimensions": [32, 32], "image_format": "png", "prefix": "favicon", "rel": "icon"}, {"dimensions": [64, 64], "image_format": "png", "prefix": "favicon", "rel": "icon"}, {"dimensions": [96, 96], "image_format": "png", "prefix": "favicon", "rel": "icon"}, {"dimensions": [180, 180], "image_format": "png", "prefix": "favicon", "rel": "icon"}, {"dimensions": [57, 57], "image_format": "png", "prefix": "apple-touch-icon", "rel": "apple-touch-icon"}, {"dimensions": [60, 60], "image_format": "png", "prefix": "apple-touch-icon", "rel": "apple-touch-icon"}, {"dimensions": [72, 72], "image_format": "png", "prefix": "apple-touch-icon", "rel": "apple-touch-icon"}, {"dimensions": [76, 76], "image_format": "png", "prefix": "apple-touch-icon", "rel": "apple-touch-icon"}, {"dimensions": [114, 114], "image_format": "png", "prefix": "apple-touch-icon", "rel": "apple-touch-icon"}, {"dimensions": [120, 120], "image_format": "png", "prefix": "apple-touch-icon", "rel": "apple-touch-icon"}, {"dimensions": [144, 144], "image_format": "png", "prefix": "apple-touch-icon", "rel": "apple-touch-icon"}, {"dimensions": [152, 152], "image_format": "png", "prefix": "apple-touch-icon", "rel": "apple-touch-icon"}, {"dimensions": [167, 167], "image_format": "png", "prefix": "apple-touch-icon", "rel": "apple-touch-icon"}, {"dimensions": [180, 180], "image_format": "png", "prefix": "apple-touch-icon", "rel": "apple-touch-icon"}, {"dimensions": [70, 70], "image_format": "png", "prefix": "mstile", "rel": null}, {"dimensions": [270, 270], "image_format": "png", "prefix": "mstile", "rel": null}, {"dimensions": [310, 310], "image_format": "png", "prefix": "mstile", "rel": null}, {"dimensions": [310, 150], "image_format": "png", "prefix": "mstile", "rel": null}, {"dimensions": [196, 196], "image_format": "png", "prefix": "favicon", "rel": "shortcut icon"}] as Favicon[];
\ No newline at end of file
diff --git a/hyperglass/ui/next.config.js b/hyperglass/ui/next.config.js
index 0ff8539..c9ee564 100644
--- a/hyperglass/ui/next.config.js
+++ b/hyperglass/ui/next.config.js
@@ -1,12 +1,9 @@
-const envData = require('/tmp/hyperglass.env.json');
-
/**
* @type {import('next').NextConfig}
*/
const nextConfig = {
reactStrictMode: true,
poweredByHeader: false,
- env: { ...envData },
typescript: {
ignoreBuildErrors: true,
},
diff --git a/hyperglass/ui/nextdev.js b/hyperglass/ui/nextdev.js
index 27f45cc..d28824b 100644
--- a/hyperglass/ui/nextdev.js
+++ b/hyperglass/ui/nextdev.js
@@ -2,21 +2,9 @@
const express = require('express');
const proxyMiddleware = require('http-proxy-middleware');
const next = require('next');
-const config = require('/tmp/hyperglass.env.json');
-
-const {
- env: { NODE_ENV },
- hyperglass: { url },
-} = config;
-
-const devProxy = {
- '/api/query/': { target: url + 'api/query/', pathRewrite: { '^/api/query/': '' } },
- '/ui/props/': { target: url + 'ui/props/', pathRewrite: { '^/ui/props/': '' } },
- '/images': { target: url + 'images', pathRewrite: { '^/images': '' } },
-};
const port = parseInt(process.env.PORT, 10) || 3000;
-const dev = NODE_ENV !== 'production';
+const dev = process.env.NODE_ENV !== 'production';
const app = next({
dir: '.', // base directory where everything is, could move to src later
dev,
@@ -30,8 +18,20 @@ app
.then(() => {
server = express();
+ const devProxy = {
+ '/api/query/': {
+ target: process.env.HYPERGLASS_URL + 'api/query/',
+ pathRewrite: { '^/api/query/': '' },
+ },
+ '/ui/props/': {
+ target: process.env.HYPERGLASS_URL + 'ui/props/',
+ pathRewrite: { '^/ui/props/': '' },
+ },
+ '/images': { target: process.env.HYPERGLASS_URL + 'images', pathRewrite: { '^/images': '' } },
+ };
+
// Set up the proxy.
- if (dev && devProxy) {
+ if (dev) {
Object.keys(devProxy).forEach(context => {
server.use(proxyMiddleware(context, devProxy[context]));
});
@@ -44,7 +44,7 @@ app
if (err) {
throw err;
}
- console.log(`> Ready on port ${port} [${NODE_ENV}]`);
+ console.log(`> Ready on port ${port} [${process.env.NODE_ENV}]`);
});
})
.catch(err => {
diff --git a/hyperglass/ui/pages/_app.tsx b/hyperglass/ui/pages/_app.tsx
index 0de9912..1afd2eb 100644
--- a/hyperglass/ui/pages/_app.tsx
+++ b/hyperglass/ui/pages/_app.tsx
@@ -1,4 +1,3 @@
-import Head from 'next/head';
import { QueryClient, QueryClientProvider } from 'react-query';
import type { AppProps } from 'next/app';
@@ -9,24 +8,9 @@ const App = (props: AppProps): JSX.Element => {
const { Component, pageProps } = props;
return (
- <>
-
- hyperglass
-
-
-
-
-
-
-
-
-
-
-
- >
+
+
+
);
};
diff --git a/hyperglass/ui/pages/_document.tsx b/hyperglass/ui/pages/_document.tsx
index 3f517c9..5949556 100644
--- a/hyperglass/ui/pages/_document.tsx
+++ b/hyperglass/ui/pages/_document.tsx
@@ -1,4 +1,6 @@
import Document, { Html, Head, Main, NextScript } from 'next/document';
+import { Favicon } from '~/components';
+import favicons from '../favicon-formats';
import type { DocumentContext, DocumentInitialProps } from 'next/document';
@@ -12,10 +14,23 @@ class MyDocument extends Document {
return (
+
+
+
+
+
+
+
+
-
+ {favicons.map((favicon, idx) => (
+
+ ))}
diff --git a/hyperglass/ui/pages/index.tsx b/hyperglass/ui/pages/index.tsx
index b16e7e2..d97c7cf 100644
--- a/hyperglass/ui/pages/index.tsx
+++ b/hyperglass/ui/pages/index.tsx
@@ -1,34 +1,20 @@
-import Head from 'next/head';
import dynamic from 'next/dynamic';
import { Meta, Loading, If, LoadError } from '~/components';
import { HyperglassProvider } from '~/context';
import { useHyperglassConfig } from '~/hooks';
-import { getFavicons } from '~/util';
-import type { GetStaticProps } from 'next';
-import type { FaviconComponent } from '~/types';
+import type { NextPage } from 'next';
const Layout = dynamic(() => import('~/components').then(i => i.Layout), {
loading: Loading,
});
-interface TIndex {
- favicons: FaviconComponent[];
-}
-
-const Index = (props: TIndex): JSX.Element => {
- const { favicons } = props;
+const Index: NextPage = () => {
const { data, error, isLoading, ready, refetch, showError, isLoadingInitial } =
useHyperglassConfig();
return (
<>
-
- {favicons.map((icon, idx) => {
- const { rel, href, type } = icon;
- return ;
- })}
-
@@ -45,11 +31,4 @@ const Index = (props: TIndex): JSX.Element => {
);
};
-export const getStaticProps: GetStaticProps = async () => {
- const favicons = await getFavicons();
- return {
- props: { favicons },
- };
-};
-
export default Index;
diff --git a/hyperglass/ui/types/config.ts b/hyperglass/ui/types/config.ts
index 4a6f01d..3f0da36 100644
--- a/hyperglass/ui/types/config.ts
+++ b/hyperglass/ui/types/config.ts
@@ -1,8 +1,6 @@
import type { Theme } from './theme';
import type { CamelCasedPropertiesDeep, CamelCasedProperties } from 'type-fest';
-// export type QueryFields = 'query_type' | 'query_target' | 'query_location' | 'query_vrf';
-
type Side = 'left' | 'right';
export type ParsedDataField = [string, keyof Route, 'left' | 'right' | 'center' | null];
@@ -189,12 +187,6 @@ export interface Favicon {
prefix: string;
}
-export interface FaviconComponent {
- rel: string;
- href: string;
- type: string;
-}
-
export type Config = CamelCasedPropertiesDeep<_ConfigDeep> & CamelCasedProperties<_ConfigShallow>;
export type ThemeConfig = CamelCasedProperties<_ThemeConfig>;
export type Content = CamelCasedProperties<_Content>;
diff --git a/hyperglass/ui/util/config.ts b/hyperglass/ui/util/config.ts
index fcc7df0..17fcd00 100644
--- a/hyperglass/ui/util/config.ts
+++ b/hyperglass/ui/util/config.ts
@@ -1,6 +1,6 @@
import { isObject } from '~/types';
-import type { Config, FaviconComponent } from '~/types';
+import type { Config } from '~/types';
export class ConfigLoadError extends Error {
public url: string = '/ui/props/';
@@ -56,14 +56,3 @@ export async function getHyperglassConfig(): Promise {
}
throw new ConfigLoadError('Unknown Error');
}
-
-export async function getFavicons(): Promise {
- const { favicons: faviconConfig } = process.env.hyperglass;
- return faviconConfig.map(icon => {
- const { image_format, dimensions, prefix } = icon;
- const [w, h] = dimensions;
- const rel = icon.rel ?? '';
- const src = `/images/favicons/${prefix}-${w}x${h}.${image_format}`;
- return { rel, href: src, type: `image/${image_format}` };
- });
-}
diff --git a/hyperglass/util/files.py b/hyperglass/util/files.py
index 5dc2c76..b2e7954 100644
--- a/hyperglass/util/files.py
+++ b/hyperglass/util/files.py
@@ -2,8 +2,8 @@
# Standard Library
import shutil
+import typing as t
from queue import Queue
-from typing import List, Tuple, Union, Iterable, Optional, Generator
from pathlib import Path
from threading import Thread
@@ -11,14 +11,8 @@ from threading import Thread
from hyperglass.log import log
-async def move_files(src: Path, dst: Path, files: Iterable[Path]) -> Tuple[str]: # noqa: C901
- """Move iterable of files from source to destination.
-
- Arguments:
- src {Path} -- Current directory of files
- dst {Path} -- Target destination directory
- files {Iterable} -- Iterable of files
- """
+async def move_files(src: Path, dst: Path, files: t.Iterable[Path]) -> t.Tuple[str]: # noqa: C901
+ """Move iterable of files from source to destination."""
def error(*args, **kwargs):
msg = ", ".join(args)
@@ -39,7 +33,7 @@ async def move_files(src: Path, dst: Path, files: Iterable[Path]) -> Tuple[str]:
except TypeError:
raise error("{p} is not a valid path", p=dst)
- if not isinstance(files, (List, Tuple, Generator)):
+ if not isinstance(files, (t.List, t.Tuple, t.Generator)):
raise error(
"{fa} must be an iterable (list, tuple, or generator). Received {f}",
fa="Files argument",
@@ -95,7 +89,7 @@ class FileCopy(Thread):
pass
-def copyfiles(src_files: Iterable[Path], dst_files: Iterable[Path]):
+def copyfiles(src_files: t.Iterable[Path], dst_files: t.Iterable[Path]):
"""Copy iterable of files from source to destination with threading."""
queue = Queue()
threads = ()
@@ -131,7 +125,7 @@ def copyfiles(src_files: Iterable[Path], dst_files: Iterable[Path]):
return True
-def check_path(path: Union[Path, str], mode: str = "r", create: bool = False) -> Optional[Path]:
+def check_path(path: t.Union[Path, str], mode: str = "r", create: bool = False) -> t.Optional[Path]:
"""Verify if a path exists and is accessible."""
result = None
@@ -157,3 +151,30 @@ def check_path(path: Union[Path, str], mode: str = "r", create: bool = False) ->
pass
return result
+
+
+def dotenv_to_dict(dotenv: t.Union[Path, str]) -> t.Dict[str, str]:
+ """Convert a .env file to a Python dict."""
+ if not isinstance(dotenv, (Path, str)):
+ raise TypeError("Argument 'file' must be a Path object or string")
+ result = {}
+ data = ""
+ if isinstance(dotenv, Path):
+ if not dotenv.exists():
+ raise FileNotFoundError("{!r} does not exist", str(dotenv))
+ with dotenv.open("r") as f:
+ data = f.read()
+ else:
+ data = dotenv
+
+ for line in (line for line in (line.strip() for line in data.splitlines()) if line):
+ parts = line.split("=")
+ if len(parts) != 2:
+ raise TypeError(
+ f"Line {line!r} is improperly formatted. "
+ "Expected a key/value pair such as 'key=value'"
+ )
+ key, value = line.split("=")
+ result[key.strip()] = value.strip()
+
+ return result
diff --git a/hyperglass/util/frontend.py b/hyperglass/util/frontend.py
index 430ceb3..8134f28 100644
--- a/hyperglass/util/frontend.py
+++ b/hyperglass/util/frontend.py
@@ -14,7 +14,7 @@ from pathlib import Path
from hyperglass.log import log
# Local
-from .files import copyfiles, check_path
+from .files import copyfiles, check_path, dotenv_to_dict
if t.TYPE_CHECKING:
# Project
@@ -239,6 +239,26 @@ def migrate_images(app_path: Path, params: "UIParameters"):
return copyfiles(src_files, dst_files)
+def write_favicon_formats(formats: t.Tuple[t.Dict[str, t.Any]]) -> None:
+ """Create a TypeScript file in the `ui` directory containing favicon formats.
+
+ This file should stay the same, unless the favicons library updates
+ supported formats.
+ """
+ # Standard Library
+ from collections import OrderedDict
+
+ file = Path(__file__).parent.parent / "ui" / "favicon-formats.ts"
+
+ # Sort each favicon definition to ensure the result stays the same
+ # time the UI build runs.
+ ordered = json.dumps([OrderedDict(sorted(fmt.items())) for fmt in formats])
+ data = "import type {{ Favicon }} from '~/types';export default {} as Favicon[];".format(
+ ordered
+ )
+ file.write_text(data)
+
+
async def build_frontend( # noqa: C901
dev_mode: bool,
dev_url: str,
@@ -249,19 +269,7 @@ async def build_frontend( # noqa: C901
timeout: int = 180,
full: bool = False,
) -> bool:
- """Perform full frontend UI build process.
-
- Securely creates temporary file, writes frontend configuration
- parameters to file as JSON. Then writes the name of the temporary
- file to /tmp/hyperglass.env.json as {"configFile": }.
-
- Webpack reads /tmp/hyperglass.env.json, loads the temporary file,
- and sets its contents to Node environment variables during the build
- process.
-
- After the build is successful, the temporary file is automatically
- closed during garbage collection.
- """
+ """Perform full frontend UI build process."""
# Standard Library
import hashlib
@@ -273,24 +281,18 @@ async def build_frontend( # noqa: C901
# Create temporary file. json file extension is added for easy
# webpack JSON parsing.
- env_file = Path("/tmp/hyperglass.env.json") # noqa: S108
+ dot_env_file = Path(__file__).parent.parent / "ui" / ".env"
+ env_config = {}
package_json = await read_package_json()
- env_vars = {
- "hyperglass": {"version": __version__},
- "env": {},
- }
-
# Set NextJS production/development mode and base URL based on
# developer_mode setting.
if dev_mode:
- env_vars["env"].update({"NODE_ENV": "development"})
- env_vars["hyperglass"].update({"url": dev_url})
+ env_config.update({"HYPERGLASS_URL": dev_url, "NODE_ENV": "development"})
else:
- env_vars["env"].update({"NODE_ENV": "production"})
- env_vars["hyperglass"].update({"url": prod_url})
+ env_config.update({"HYPERGLASS_URL": prod_url, "NODE_ENV": "production"})
# Check if hyperglass/ui/node_modules has been initialized. If not,
# initialize it.
@@ -310,71 +312,70 @@ async def build_frontend( # noqa: C901
images_dir = app_path / "static" / "images"
favicon_dir = images_dir / "favicons"
- try:
- if not favicon_dir.exists():
- favicon_dir.mkdir()
- async with Favicons(
- source=params.web.logo.favicon,
- output_directory=favicon_dir,
- base_url="/images/favicons/",
- ) as favicons:
- await favicons.generate()
- log.debug("Generated {} favicons", favicons.completed)
- env_vars["hyperglass"].update({"favicons": favicons.formats()})
- build_data = {
- "params": params.export_dict(),
- "version": __version__,
- "package_json": package_json,
- }
- build_json = json.dumps(build_data, default=str)
- # Create SHA256 hash from all parameters passed to UI, use as
- # build identifier.
- build_id = hashlib.sha256(build_json.encode()).hexdigest()
+ if not favicon_dir.exists():
+ favicon_dir.mkdir()
- # Read hard-coded environment file from last build. If build ID
- # matches this build's ID, don't run a new build.
- if env_file.exists() and not force:
- with env_file.open("r") as ef:
- ef_id = json.load(ef).get("buildId", "empty")
- log.debug("Previous Build ID: {}", ef_id)
+ async with Favicons(
+ source=params.web.logo.favicon,
+ output_directory=favicon_dir,
+ base_url="/images/favicons/",
+ ) as favicons:
+ await favicons.generate()
+ log.debug("Generated {} favicons", favicons.completed)
+ write_favicon_formats(favicons.formats())
- if ef_id == build_id:
- log.debug("UI parameters unchanged since last build, skipping UI build...")
- return True
+ build_data = {
+ "params": params.export_dict(),
+ "version": __version__,
+ "package_json": package_json,
+ }
- env_vars["buildId"] = build_id
+ build_json = json.dumps(build_data, default=str)
- env_file.write_text(json.dumps(env_vars, default=str))
- log.debug("Wrote UI environment file '{}'", str(env_file))
+ # Create SHA256 hash from all parameters passed to UI, use as
+ # build identifier.
+ build_id = hashlib.sha256(build_json.encode()).hexdigest()
- # Initiate Next.JS export process.
- if any((not dev_mode, force, full)):
- log.info("Starting UI build")
- initialize_result = await node_initial(timeout, dev_mode)
- build_result = await build_ui(app_path=app_path)
+ # Read hard-coded environment file from last build. If build ID
+ # matches this build's ID, don't run a new build.
+ if dot_env_file.exists() and not force:
+ env_data = dotenv_to_dict(dot_env_file)
+ env_build_id = env_data.get("HYPERGLASS_BUILD_ID", "None")
+ log.debug("Previous Build ID: {!r}", env_build_id)
- if initialize_result:
- log.debug(initialize_result)
- elif initialize_result == "":
- log.debug("Re-initialized node_modules")
+ if env_build_id == build_id:
+ log.debug("UI parameters unchanged since last build, skipping UI build...")
+ return True
- if build_result:
- log.success("Completed UI build")
- elif dev_mode and not force:
- log.debug("Running in developer mode, did not build new UI files")
+ env_config.update({"HYPERGLASS_BUILD_ID": build_id})
- migrate_images(app_path, params)
+ dot_env_file.write_text("\n".join(f"{k}={v}" for k, v in env_config.items()))
+ log.debug("Wrote UI environment file {!r}", str(dot_env_file))
- generate_opengraph(
- params.web.opengraph.image,
- 1200,
- 630,
- images_dir,
- params.web.theme.colors.black,
- )
+ # Initiate Next.JS export process.
+ if any((not dev_mode, force, full)):
+ log.info("Starting UI build")
+ initialize_result = await node_initial(timeout, dev_mode)
+ build_result = await build_ui(app_path=app_path)
- except Exception as err:
- log.error(err)
- raise RuntimeError(str(err)) from None
+ if initialize_result:
+ log.debug(initialize_result)
+ elif initialize_result == "":
+ log.debug("Re-initialized node_modules")
+
+ if build_result:
+ log.success("Completed UI build")
+ elif dev_mode and not force:
+ log.debug("Running in developer mode, did not build new UI files")
+
+ migrate_images(app_path, params)
+
+ generate_opengraph(
+ params.web.opengraph.image,
+ 1200,
+ 630,
+ images_dir,
+ params.web.theme.colors.black,
+ )
return True
diff --git a/hyperglass/util/tests/test_files.py b/hyperglass/util/tests/test_files.py
new file mode 100644
index 0000000..ed557ad
--- /dev/null
+++ b/hyperglass/util/tests/test_files.py
@@ -0,0 +1,50 @@
+"""Test file-related utilities."""
+
+# Standard Library
+import tempfile
+from pathlib import Path
+
+# Third Party
+import pytest
+
+# Local
+from ..files import dotenv_to_dict
+
+ENV_TEST = """KEY1=VALUE1
+KEY2=VALUE2
+KEY3=VALUE3
+ """
+
+
+def test_dotenv_to_dict_string():
+ result = dotenv_to_dict(ENV_TEST)
+ assert result.get("KEY1") == "VALUE1"
+ assert result.get("KEY2") == "VALUE2"
+ assert result.get("KEY3") == "VALUE3"
+
+
+def test_dotenv_to_dict_file():
+ _, filename = tempfile.mkstemp()
+ file = Path(filename)
+ with file.open("w+") as f:
+ f.write(ENV_TEST)
+ result = dotenv_to_dict(file)
+ assert result.get("KEY1") == "VALUE1"
+ assert result.get("KEY2") == "VALUE2"
+ assert result.get("KEY3") == "VALUE3"
+ file.unlink()
+
+
+def test_dotenv_to_dict_raises_type_error():
+ with pytest.raises(TypeError):
+ dotenv_to_dict(True)
+
+
+def test_dotenv_to_dict_raises_filenotfounderror():
+ with pytest.raises(FileNotFoundError):
+ dotenv_to_dict(Path("/tmp/not-a-thing")) # noqa: S108
+
+
+def test_dotenv_invalid_format():
+ with pytest.raises(TypeError):
+ dotenv_to_dict("this should raise an error")